From 7914bd3fdf7e826a10efdb482983fb0d06847fea Mon Sep 17 00:00:00 2001 From: "GuavaDealer (Idan Nehama)" Date: Sun, 27 Sep 2026 20:46:32 +0300 Subject: [PATCH 1/8] refactor: rewrite codebase in Kotlin and reorganize project structure - Migrate all Java sources across base, platform API, Paper, and Velocity modules to idiomatic Kotlin. - Relocate subproject directories into src/ and update project paths in Gradle settings. - Introduce buildSrc Kotlin DSL extensions, configure Java 25 toolchains, and enable Gradle feature previews. - Modernize dependencies, Gradle wrapper, and version catalog declarations across all modules. - Refactor platform API abstractions to eliminate legacy I-prefixed interface conventions in favor of idiomatic Kotlin contracts. - Implement a Kotlin DSL for Brigadier commands with custom command node builders and player argument resolution. - Overhaul dialog session management and packet handling, incorporating PacketEvents compatibility adjustments. - Rewrite YAML changelog storage, configuration parsing, and localization resource loaders in Kotlin. - Update Paper and Velocity platform adapters, player wrappers, and event dispatchers to align with revised platform contracts. Signed-off-by: GuavaDealer (Idan Nehama) --- .github/workflows/build.yml | 10 +- .github/workflows/submit-dependencies.yml | 6 +- .gitignore | 2 + .idea/.gitignore | 1 + .idea/codeStyles/Project.xml | 51 +++ .idea/codeStyles/codeStyleConfig.xml | 5 + .idea/gradle.xml | 25 +- .idea/inspectionProfiles/Project_Default.xml | 2 +- .idea/jsonSchemas.xml | 25 ++ .idea/kotlinc.xml | 7 + .idea/misc.xml | 2 +- .idea/modules.xml | 16 +- README.md | 133 +++--- base/build.gradle.kts | 20 - .../changelogs/base/Entrypoints.java | 11 - .../changelogs/base/ServerChangelogs.java | 153 ------- .../base/command/BrigadierCommandNode.java | 27 -- .../base/command/DialogSubCommands.java | 59 --- .../base/command/InfoSubCommand.java | 23 - .../base/command/ReloadSubCommand.java | 51 --- .../base/compat/PacketEventsFix.java | 73 --- .../compat/PacketEventsNativeItemManager.java | 31 -- .../changelogs/base/config/PluginConfig.java | 134 ------ .../changelogs/base/data/ChangelogEntry.java | 23 - .../base/data/ChangelogStorage.java | 37 -- .../data/storage/YamlChangelogStorage.java | 181 -------- .../changelogs/base/dialog/DialogPackets.java | 131 ------ .../base/dialog/DialogSessionManager.java | 190 -------- .../changelogs/base/dialog/IDialog.java | 79 ---- .../base/dialog/ui/ChangelogDialog.java | 176 -------- .../ui/editor/ChangelogEditorDialog.java | 232 ---------- .../base/dialog/ui/editor/EditorSession.java | 134 ------ .../base/event/ChangelogJoinListener.java | 57 --- .../base/lang/TranslationSource.java | 160 ------- .../changelogs/base/util/ResourceUtil.java | 56 --- build.gradle.kts | 77 ++++ buildSrc/build.gradle.kts | 13 + buildSrc/settings.gradle.kts | 9 + buildSrc/src/main/kotlin/BuildExtensions.kt | 28 ++ gradle.properties | 24 +- gradle/libs.versions.toml | 19 +- gradle/wrapper/gradle-wrapper.properties | 16 +- .../paper/PaperChangelogsPlatform.java | 68 --- .../paper/command/PaperCommandManager.java | 44 -- .../command/WrappedCommandSourceStack.java | 38 -- .../paper/event/PaperEventManager.java | 77 ---- .../paper/event/PaperEventMappings.java | 26 -- .../paper/item/PaperNativeItemManager.java | 35 -- .../paper/meta/PaperPlatformMeta.java | 34 -- .../paper/meta/PaperPluginChangelogsMeta.java | 36 -- ...erConfigurationPhaseConnectionWrapper.java | 75 ---- .../paper/player/PaperPlayerManager.java | 45 -- .../paper/player/PaperPlayerWrapper.java | 68 --- platformapi/build.gradle.kts | 15 - .../platformapi/ChangelogsPlatform.java | 30 -- .../changelogs/platformapi/Entrypoint.java | 15 - .../changelogs/platformapi/IWrapper.java | 7 - .../IBasePermissionHolderAudienceWrapper.java | 22 - .../platformapi/command/ICommandManager.java | 16 - .../platformapi/command/ICommandSource.java | 9 - .../platformapi/event/IEventManager.java | 31 -- .../platformapi/event/IdentifiedListener.java | 23 - .../platformapi/event/impl/IEvent.java | 4 - .../PlayerEnterConfigurationPhaseEvent.java | 17 - .../event/impl/PlayerJoinEvent.java | 13 - .../event/util/MethodDispatchingListener.java | 57 --- .../event/util/PlatformEventMappings.java | 34 -- .../platformapi/item/NativeItemManager.java | 13 - .../platformapi/meta/ChangelogsMeta.java | 16 - .../platformapi/meta/PlatformMeta.java | 10 - .../platformapi/player/IPlayer.java | 24 - .../platformapi/player/IPlayerManager.java | 17 - settings.gradle.kts | 46 +- src/base/build.gradle.kts | 14 + .../codingcat/changelogs/base/Entrypoints.kt | 17 + .../changelogs/base/ServerChangelogs.kt | 213 +++++++++ .../base/command/AbstractCommand.kt | 232 ++++++++++ .../base/command/BrigadierCommandNode.kt | 46 ++ .../changelogs/base/command/CommandBuilder.kt | 42 ++ .../changelogs/base/command/CommandDsl.kt | 12 + .../base/command/ServerChangelogsCommand.kt | 111 +++++ .../base/command/argument/PlayerArgument.kt | 100 +++++ .../changelogs/base/compat/PacketEventsFix.kt | 87 ++++ .../compat/PacketEventsNativeItemManager.kt | 25 ++ .../changelogs/base/config/PluginConfig.kt | 138 ++++++ .../changelogs/base/data/ChangelogEntry.kt | 25 ++ .../changelogs/base/data/ChangelogStorage.kt | 92 ++++ .../base/data/storage/YamlChangelogStorage.kt | 174 ++++++++ .../changelogs/base/dialog/DialogPackets.kt | 199 +++++++++ .../base/dialog/DialogSessionManager.kt | 211 +++++++++ .../changelogs/base/dialog/PluginDialog.kt | 106 +++++ .../base/dialog/ui/ChangelogDialog.kt | 247 ++++++++++ .../dialog/ui/editor/ChangelogEditorDialog.kt | 421 ++++++++++++++++++ .../base/dialog/ui/editor/EditorSession.kt | 89 ++++ .../base/event/ChangelogJoinListener.kt | 56 +++ .../changelogs/base/lang/TranslationSource.kt | 209 +++++++++ .../changelogs/base/util/ResourceUtil.kt | 40 ++ .../src/main/resources/defaults/config.yml | 2 +- .../base}/src/main/resources/lang/en_US.yml | 0 .../base}/src/main/resources/lang/ru_RU.yml | 0 {paper => src/paper}/build.gradle.kts | 13 +- .../paper/PaperChangelogsPlatform.kt | 82 ++++ .../paper/command/PaperCommandManager.kt | 40 ++ .../command/WrappedCommandSourceStack.kt | 28 ++ .../paper/event/PaperEventManager.kt | 68 +++ .../paper/event/PaperEventMappings.kt | 34 ++ .../paper/item/PaperNativeItemManager.kt | 38 ++ .../paper/meta/PaperPlatformMeta.kt | 24 + .../paper/meta/PaperPluginChangelogsMeta.kt | 20 + ...aperConfigurationPhaseConnectionWrapper.kt | 56 +++ .../paper/player/PaperPlayerManager.kt | 28 ++ .../paper/player/PaperPlayerWrapper.kt | 39 ++ src/platformapi/build.gradle.kts | 12 + .../platformapi/ChangelogsPlatform.kt | 57 +++ .../changelogs/platformapi/Entrypoint.kt | 28 ++ .../audience/PermissionAudienceWrapper.kt | 40 ++ .../command/PlatformCommandManager.kt | 30 ++ .../command/PlatformCommandSource.kt | 14 + .../platformapi/event/IdentifiedListener.kt | 24 + .../platformapi/event/PlatformEventManager.kt | 68 +++ .../platformapi/event/impl/PlatformEvent.kt | 6 + .../PlayerEnterConfigurationPhaseEvent.kt | 14 + .../platformapi/event/impl/PlayerJoinEvent.kt | 11 + .../event/util/MethodDispatchingListener.kt | 88 ++++ .../event/util/PlatformEventMappings.kt | 48 ++ .../platformapi/item/NativeItemManager.kt | 25 ++ .../platformapi/meta/ChangelogsMeta.kt | 26 ++ .../platformapi/meta/PlatformMeta.kt | 18 + .../platformapi/player/PlatformPlayer.kt | 46 ++ .../player/PlatformPlayerManager.kt | 42 ++ {velocity => src/velocity}/build.gradle.kts | 17 +- .../velocity/VelocityChangelogsPlatform.kt | 102 +++++ .../command/VelocityCommandManager.kt | 34 ++ .../velocity/command/WrappedCommandSource.kt | 25 ++ .../velocity/event/VelocityEventManager.kt | 75 ++++ .../velocity/event/VelocityEventMappings.kt | 33 ++ .../velocity/meta/VelocityPlatformMeta.kt | 17 + .../meta/VelocityPluginChangelogsMeta.kt | 21 + .../velocity/player/VelocityPlayerManager.kt | 32 ++ .../velocity/player/VelocityPlayerWrapper.kt | 45 ++ .../velocity/VelocityChangelogsPlatform.java | 81 ---- .../command/VelocityCommandManager.java | 40 -- .../command/WrappedCommandSource.java | 38 -- .../velocity/event/VelocityEventManager.java | 78 ---- .../velocity/event/VelocityEventMappings.java | 24 - .../velocity/meta/VelocityPlatformMeta.java | 25 -- .../meta/VelocityPluginChangelogsMeta.java | 35 -- .../player/VelocityPlayerManager.java | 42 -- .../player/VelocityPlayerWrapper.java | 70 --- 149 files changed, 4769 insertions(+), 3514 deletions(-) create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/jsonSchemas.xml create mode 100644 .idea/kotlinc.xml delete mode 100644 base/build.gradle.kts delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/Entrypoints.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/ServerChangelogs.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/command/BrigadierCommandNode.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/command/DialogSubCommands.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/command/InfoSubCommand.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/command/ReloadSubCommand.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/compat/PacketEventsFix.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/compat/PacketEventsNativeItemManager.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/config/PluginConfig.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/data/ChangelogEntry.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/data/ChangelogStorage.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/dialog/DialogPackets.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/dialog/DialogSessionManager.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/dialog/IDialog.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/event/ChangelogJoinListener.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/lang/TranslationSource.java delete mode 100644 base/src/main/java/com/codingcat/changelogs/base/util/ResourceUtil.java create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/settings.gradle.kts create mode 100644 buildSrc/src/main/kotlin/BuildExtensions.kt delete mode 100644 paper/src/main/java/com/codingcat/changelogs/paper/PaperChangelogsPlatform.java delete mode 100644 paper/src/main/java/com/codingcat/changelogs/paper/command/PaperCommandManager.java delete mode 100644 paper/src/main/java/com/codingcat/changelogs/paper/command/WrappedCommandSourceStack.java delete mode 100644 paper/src/main/java/com/codingcat/changelogs/paper/event/PaperEventManager.java delete mode 100644 paper/src/main/java/com/codingcat/changelogs/paper/event/PaperEventMappings.java delete mode 100644 paper/src/main/java/com/codingcat/changelogs/paper/item/PaperNativeItemManager.java delete mode 100644 paper/src/main/java/com/codingcat/changelogs/paper/meta/PaperPlatformMeta.java delete mode 100644 paper/src/main/java/com/codingcat/changelogs/paper/meta/PaperPluginChangelogsMeta.java delete mode 100644 paper/src/main/java/com/codingcat/changelogs/paper/player/PaperConfigurationPhaseConnectionWrapper.java delete mode 100644 paper/src/main/java/com/codingcat/changelogs/paper/player/PaperPlayerManager.java delete mode 100644 paper/src/main/java/com/codingcat/changelogs/paper/player/PaperPlayerWrapper.java delete mode 100644 platformapi/build.gradle.kts delete mode 100644 platformapi/src/main/java/com/codingcat/changelogs/platformapi/ChangelogsPlatform.java delete mode 100644 platformapi/src/main/java/com/codingcat/changelogs/platformapi/Entrypoint.java delete mode 100644 platformapi/src/main/java/com/codingcat/changelogs/platformapi/IWrapper.java delete mode 100644 platformapi/src/main/java/com/codingcat/changelogs/platformapi/audience/IBasePermissionHolderAudienceWrapper.java delete mode 100644 platformapi/src/main/java/com/codingcat/changelogs/platformapi/command/ICommandManager.java delete mode 100644 platformapi/src/main/java/com/codingcat/changelogs/platformapi/command/ICommandSource.java delete mode 100644 platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/IEventManager.java delete mode 100644 platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/IdentifiedListener.java delete mode 100644 platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/impl/IEvent.java delete mode 100644 platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/impl/PlayerEnterConfigurationPhaseEvent.java delete mode 100644 platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/impl/PlayerJoinEvent.java delete mode 100644 platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/util/MethodDispatchingListener.java delete mode 100644 platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappings.java delete mode 100644 platformapi/src/main/java/com/codingcat/changelogs/platformapi/item/NativeItemManager.java delete mode 100644 platformapi/src/main/java/com/codingcat/changelogs/platformapi/meta/ChangelogsMeta.java delete mode 100644 platformapi/src/main/java/com/codingcat/changelogs/platformapi/meta/PlatformMeta.java delete mode 100644 platformapi/src/main/java/com/codingcat/changelogs/platformapi/player/IPlayer.java delete mode 100644 platformapi/src/main/java/com/codingcat/changelogs/platformapi/player/IPlayerManager.java create mode 100644 src/base/build.gradle.kts create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/Entrypoints.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/ServerChangelogs.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/command/AbstractCommand.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/command/BrigadierCommandNode.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/command/CommandBuilder.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/command/CommandDsl.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/command/ServerChangelogsCommand.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/command/argument/PlayerArgument.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/compat/PacketEventsFix.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/compat/PacketEventsNativeItemManager.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogEntry.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogStorage.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/DialogPackets.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/DialogSessionManager.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/PluginDialog.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/event/ChangelogJoinListener.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/lang/TranslationSource.kt create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/util/ResourceUtil.kt rename {base => src/base}/src/main/resources/defaults/config.yml (98%) rename {base => src/base}/src/main/resources/lang/en_US.yml (100%) rename {base => src/base}/src/main/resources/lang/ru_RU.yml (100%) rename {paper => src/paper}/build.gradle.kts (78%) create mode 100644 src/paper/src/main/kotlin/com/codingcat/changelogs/paper/PaperChangelogsPlatform.kt create mode 100644 src/paper/src/main/kotlin/com/codingcat/changelogs/paper/command/PaperCommandManager.kt create mode 100644 src/paper/src/main/kotlin/com/codingcat/changelogs/paper/command/WrappedCommandSourceStack.kt create mode 100644 src/paper/src/main/kotlin/com/codingcat/changelogs/paper/event/PaperEventManager.kt create mode 100644 src/paper/src/main/kotlin/com/codingcat/changelogs/paper/event/PaperEventMappings.kt create mode 100644 src/paper/src/main/kotlin/com/codingcat/changelogs/paper/item/PaperNativeItemManager.kt create mode 100644 src/paper/src/main/kotlin/com/codingcat/changelogs/paper/meta/PaperPlatformMeta.kt create mode 100644 src/paper/src/main/kotlin/com/codingcat/changelogs/paper/meta/PaperPluginChangelogsMeta.kt create mode 100644 src/paper/src/main/kotlin/com/codingcat/changelogs/paper/player/PaperConfigurationPhaseConnectionWrapper.kt create mode 100644 src/paper/src/main/kotlin/com/codingcat/changelogs/paper/player/PaperPlayerManager.kt create mode 100644 src/paper/src/main/kotlin/com/codingcat/changelogs/paper/player/PaperPlayerWrapper.kt create mode 100644 src/platformapi/build.gradle.kts create mode 100644 src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/ChangelogsPlatform.kt create mode 100644 src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/Entrypoint.kt create mode 100644 src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/audience/PermissionAudienceWrapper.kt create mode 100644 src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/command/PlatformCommandManager.kt create mode 100644 src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/command/PlatformCommandSource.kt create mode 100644 src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/IdentifiedListener.kt create mode 100644 src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/PlatformEventManager.kt create mode 100644 src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/impl/PlatformEvent.kt create mode 100644 src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/impl/PlayerEnterConfigurationPhaseEvent.kt create mode 100644 src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/impl/PlayerJoinEvent.kt create mode 100644 src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/util/MethodDispatchingListener.kt create mode 100644 src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappings.kt create mode 100644 src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/item/NativeItemManager.kt create mode 100644 src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/meta/ChangelogsMeta.kt create mode 100644 src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/meta/PlatformMeta.kt create mode 100644 src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/player/PlatformPlayer.kt create mode 100644 src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/player/PlatformPlayerManager.kt rename {velocity => src/velocity}/build.gradle.kts (71%) create mode 100644 src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/VelocityChangelogsPlatform.kt create mode 100644 src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/command/VelocityCommandManager.kt create mode 100644 src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/command/WrappedCommandSource.kt create mode 100644 src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/event/VelocityEventManager.kt create mode 100644 src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/event/VelocityEventMappings.kt create mode 100644 src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/meta/VelocityPlatformMeta.kt create mode 100644 src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/meta/VelocityPluginChangelogsMeta.kt create mode 100644 src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/player/VelocityPlayerManager.kt create mode 100644 src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/player/VelocityPlayerWrapper.kt delete mode 100644 velocity/src/main/java/com/codingcat/changelogs/velocity/VelocityChangelogsPlatform.java delete mode 100644 velocity/src/main/java/com/codingcat/changelogs/velocity/command/VelocityCommandManager.java delete mode 100644 velocity/src/main/java/com/codingcat/changelogs/velocity/command/WrappedCommandSource.java delete mode 100644 velocity/src/main/java/com/codingcat/changelogs/velocity/event/VelocityEventManager.java delete mode 100644 velocity/src/main/java/com/codingcat/changelogs/velocity/event/VelocityEventMappings.java delete mode 100644 velocity/src/main/java/com/codingcat/changelogs/velocity/meta/VelocityPlatformMeta.java delete mode 100644 velocity/src/main/java/com/codingcat/changelogs/velocity/meta/VelocityPluginChangelogsMeta.java delete mode 100644 velocity/src/main/java/com/codingcat/changelogs/velocity/player/VelocityPlayerManager.java delete mode 100644 velocity/src/main/java/com/codingcat/changelogs/velocity/player/VelocityPlayerWrapper.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4ef0f5c..b288804 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,20 +12,20 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7.0.0 - name: Set up JDK 25 - uses: actions/setup-java@v4 + uses: actions/setup-java@v6.0.1 with: java-version: '25' distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 - name: Build with Gradle Wrapper run: ./gradlew build - name: Upload built artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: Platform Artifacts path: |- paper/build/libs/*-all.jar - velocity/build/libs/*-all.jar \ No newline at end of file + velocity/build/libs/*-all.jar diff --git a/.github/workflows/submit-dependencies.yml b/.github/workflows/submit-dependencies.yml index f97282d..27b1ef5 100644 --- a/.github/workflows/submit-dependencies.yml +++ b/.github/workflows/submit-dependencies.yml @@ -10,11 +10,11 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7.0.1 - name: Set up JDK 25 - uses: actions/setup-java@v4 + uses: actions/setup-java@v6.0.1 with: java-version: '25' distribution: 'temurin' - name: Generate and submit dependency graph - uses: gradle/actions/dependency-submission@v6 + uses: gradle/actions/dependency-submission@v6.0.1 diff --git a/.gitignore b/.gitignore index 9384b50..853b2e2 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,8 @@ bin/ ### Local Data ### .env +final/* +final_bak/* ### run ### run/ diff --git a/.idea/.gitignore b/.idea/.gitignore index f9f1384..49689eb 100644 --- a/.idea/.gitignore +++ b/.idea/.gitignore @@ -7,3 +7,4 @@ /compiler.xml /dbnavigator.xml /discord.xml +/jarRepositories.xml diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..466f0c0 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,51 @@ + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..c35e344 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 84925fb..5ac7d69 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -4,18 +4,33 @@ + - + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 284a8db..1628f09 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -4,4 +4,4 @@ - + \ No newline at end of file diff --git a/.idea/jsonSchemas.xml b/.idea/jsonSchemas.xml new file mode 100644 index 0000000..eec2141 --- /dev/null +++ b/.idea/jsonSchemas.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..4f368d4 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index e5c2c06..1b403db 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -2,4 +2,4 @@ - + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml index 61d4257..9153a9d 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -2,10 +2,16 @@ - - - - + + + + + + + + + + - + \ No newline at end of file diff --git a/README.md b/README.md index bf6d6ab..7c08aa9 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,17 @@ [![Github Repo](https://img.shields.io/badge/github-repo-blue?logo=github&style=for-the-badge)](https://github.com/codingcat2468/ServerChangelogs) [![License](https://img.shields.io/github/license/codingcat2468/ServerChangelogs?style=for-the-badge)](https://github.com/codingcat2468/ServerChangelogs/blob/master/LICENSE) - -Keep players up-to-date on server changes using intuitive, dialog-based UIs! +Keep players up to date on server changes using intuitive, dialog-based UIs! -
+--- -This plugin allows you to show changelogs to your players in a very accessible form, using minecraft's new-ish **dialog UIs**! Compared to similar solutions in chat / using books, this approach can't be overseen or be hard to read, and allows players to review earlier changes by just scrolling down! +This plugin allows you to show changelogs to your players in a very accessible form, using minecraft's newish **dialog UIs**! Compared to similar solutions in chat / using books, this approach can't be overseen or be hard to read and allows players to review earlier changes by just scrolling down! ## Features + Currently, the plugin supports the following features: + - Showing **new changelogs** to players upon **joining** - **Configuration-phase** dialogs that will show **before** players even **join the world** - Tracking **whether** a player **has read** a certain changelog @@ -28,80 +29,60 @@ Currently, the plugin supports the following features: - A **completely customizable** changelog screen - Customizable **translations** based on the player's **client language** -## Supported Server Software & Versions +## Supported server software & versions + ServerChangelogs is currently available as: + - a **Paper** plugin (meaning it will only run on **Paper** servers, including forks like **Purpur** etc.) - a **Velocity** plugin (which runs on the proxy itself, no matter what backend servers are used) For paper, the initial version of the plugin is built against **26.1.2**, which doesn't always imply it won't work on lower versions. The backwards-compatibility of the Paper API can sometimes work pretty well, but that's not always the case. + This should again not be a concern for velocity, since the version of the client matters less there. Do note that this plugin relies on dialogs a lot, which are only available starting with `1.21.6`. In case something does break on earlier versions, let me know and [create an issue](https://github.com/codingcat2468/ServerChangelogs/issues/new) *(as long as you're not trying to run it on 1.8)*, and I **might** be able to take a look at it! Also, keep in mind that I won't keep the version number above updated very frequently, and that updates aren't always necessary to support a new version, so just give it a try! ## Installation -Installing this plugin should be as simple as **downloading** the appropriate version, and **copying the file** into your `plugins` folder! + +Installing this plugin should be as simple as **downloading** the appropriate version and **copying the file** into your `plugins` folder! + After that, **restart your server** and the plugin should **generate** all the config files **automatically** (you can find them in the `plugins/ServerChangelogs` directory!) -## Commands & Permissions +## Commands & permissions + The plugin provides a few commands, to which access can be controlled using the following permissions: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Plugin CommandPermissionDescription
/scl infoserver_changelogs.command.infoProvides version information about the plugin & platform
/scl createserver_changelogs.command.createOpens the UI to publish a new changelog
/scl viewserver_changelogs.command.viewOpens the player-facing changelog UI
/scl reloadserver_changelogs.command.reloadReloads the plugin configuration and all languages
/changelogserver_changelogs.dedicated_commandDoes the same as /scl view, but using a dedicated command; this can be more intuitive for players! Note that this is NOT /changelogs (no S at the end).
-server_changelogs.manageAllows the user to edit/delete existing changelogs through buttons in the changelog UI
- -Note that `/server_changelogs`, `/changelogs` and `/scl` are just **aliases** of the -**same command** (they do the same thing and are controlled by the same permissions)! - -## Configuration & Language -The plugin configuration can be found in `config.yml` and includes a few basic options for storage, formatting and UI layout. + +| Plugin Command | Permission | Description | +|:---------------|:--------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `/scl info` | `server_changelogs.command.info` | Provides version information about the plugin & platform | +| `/scl create` | `server_changelogs.command.create` | Opens the UI to publish a new changelog | +| `/scl view` | `server_changelogs.command.view` | Opens the player-facing changelog UI | +| `/scl reload` | `server_changelogs.command.reload` | Reloads the plugin configuration and all languages | +| `/changelog` | `server_changelogs.dedicated_command` | Does the same as `/scl view`, but using a dedicated command; this can be more intuitive for players! Note that this is **NOT** `/changelogs` (no S at the end). | +| - | `server_changelogs.manage` | Allows the user to edit/delete existing changelogs through buttons in the changelog UI | + +Note that `/server_changelogs`, `/changelogs` and `/scl` are just **aliases** of the **same command** (they do the same thing and are controlled by the same permissions)! + +## Configuration & language + +The plugin configuration can be found in `config.yml` and includes a few basic options for storage, formatting, and UI layout. Most of the actual UI customization can be done in the **language files** (found in the `lang` directory), which are formatted using [MiniMessage](https://docs.papermc.io/adventure/minimessage/format). If you're not sure what part of the file to edit, just search for the text you're trying to change in-game in the file! Out of the box, the plugin currently only supports **American English** (`en_US`), but more language files can be added using the same country code format (e.g. `de_DE`, `fr_FR`, `en_GB`). -### Custom MiniMessage Tags & Sub-Translations -The plugin registers a few custom **MiniMessage Tags** you can use in any of the translations, including: +### Custom MiniMessage tags & sub-translations + +The plugin registers a few custom **MiniMessage tags** you can use in any of the translations, including: + - ``: The plugin prefix defined in the `prefix` translation - ``: The (internally defined) name of the plugin - ``: The (internally defined) plugin description - ``: The currently running version of the plugin - ``: A comma-separated list of plugin authors -- ``: The (colored/formatted) name of the current platform *(e.g. Paper)* +- ``: The (colored/formatted) name of the current platform *(e.g., Paper)* - ``: The current version that the platform the plugin runs on is reporting -- ``: Allows you to include values of other translations in another translations. Unlike MM's `` tag, this is specifically made to work with the plugin's translations! Note that this **does not** support arguments as of now. +- ``: Allows you to include values of other translations in other translations. Unlike MM's `` tag, this is specifically made to work with the plugin's translations! Note that this **does not** support arguments as of now. Throughout the language files, you'll also come across tags looking something like this: ``. @@ -110,15 +91,41 @@ Those are **argument tags**, which contain specific text defined by the plugin ( If multiple arguments are present in a certain translation, they can be identified by the number after the `:`. ## Roadmap + ServerChangelogs was originally made for a **smaller minecraft server** I'm a part of, to make players aware of changes without having to rely on e.g. **Discord**. Because we wanted this feature to be available pretty fast, the plugin initially won't have as many features as I'd like it to have. The roadmap below contains some of the features that I'm **planning to add** in the future: -- [ ] **JDBC (relational) database support**: Currently, the plugin stores all changelog data (including who has already seen what changelogs) in a plain YAML file. The reason for this mostly comes down to the stuff above (we wanted a solution fast), so we didn't prioritize storage. This does start to cause problems over time, since the amount of data that has to be (de-)serialized for each startup/write operation increases by a lot! A proper implementation would also have to consider things like auto-migration from the existing YAML files. -- [ ] **Pre-defined authors**: Rather than having to enter the name of the changelog's author every time (possibly including formatting), there could be a list of pre-defined authors in the plugin config that a user could choose from. -- [ ] **Customizable changelog dialog layout**: Currently, the changelog dialog always shows all previous changelogs, with unread ones marked red. In the future, it'd be nice to have an option for this, e.g. so players on join could also only see the most recent changelog. -- [ ] **Paged changelogs**: At the moment, all existing changelogs are just added to the dialog without any additional checks. While this is fine with just a few of them, it could quickly become an issue with a long history of changelogs. So adding a page system with a configurable amount of changelogs per page would be great! -- [ ] **Player first-join storage**: Right now, changelogs are just displayed to players upon joining whenever they haven't read them (except for the **first time** a player joins a server). This can be very confusing to new-ish players, since they weren't there for all the previous changes to begin with! A possible (while not 100% perfect) solution to this would be storing when the player initially joined the server / was first seen by the plugin, to then only display relevant changelogs to them. -- [ ] **Other UI types**: The changes in this lower section are planned for a not-so-close future, the stuff above is more relevant for now! Anyway, even though other UI types (like chat, books, etc.) may not be as intuitive, some server owners might still prefer them. While there are plugins out there that can already do this, it would be nice for those to be available along with the additional features this plugin provides. -- [ ] **A poll/voting system?**: A bit of a far-fetched idea, but since changelogs include a lot of, well, *change*, they can sometimes be controversial along players. I feel like a voting system might fit this plugin kind of well, so players can immediately share their opinion on a specific change right when they see it! - -> Contributions over on [GitHub](https://github.com/codingcat2468/ServerChangelogs) are always welcome, so if you do want to help out, it'd be nice to see if you are interested in implementing/helping with some of the above ideas! \ No newline at end of file + +- [ ] **JDBC (relational) database support**: + Currently, the plugin stores all changelog data (including who has already seen what changelogs) in a plain YAML file. + The reason for this mostly comes down to the stuff above (we wanted a solution fast), so we didn't prioritize storage. + This does start to cause problems over time, since the amount of data that has to be (de-)serialized for each startup/write operation increases by a lot! + A proper implementation would also have to consider things like auto-migration from the existing YAML files. + +- [ ] **Pre-defined authors**: + Rather than having to enter the name of the changelog's author every time (possibly including formatting), there could be a list of pre-defined authors in the plugin config that a user could choose from. + +- [ ] **Customizable changelog dialog layout**: + Currently, the changelog dialog always shows all previous changelogs, with unread ones marked red. + In the future, it'd be nice to have an option for this, e.g., so players on join could also only see the most recent changelog. + +- [ ] **Paged changelogs**: + At the moment, all existing changelogs are just added to the dialog without any additional checks. + While this is fine with just a few of them, it could quickly become an issue with a long history of changelogs. + So adding a page system with a configurable amount of changelogs per page would be great! + +- [ ] **Player first-join storage**: + Right now, changelogs are just displayed to players upon joining whenever they haven't read them (except for the **first time** a player joins a server). + This can be very confusing to newish players, since they weren't there for all the previous changes to begin with! + A possible (while not 100% perfect) solution to this would be storing when the player initially joined the server / was first seen by the plugin, to then only display relevant changelogs to them. + +- [ ] **Other UI types**: + The changes in this lower section are planned for a not-so-close future, the stuff above is more relevant for now! + Anyway, even though other UI types (like chat, books, etc.) may not be as intuitive, some server owners might still prefer them. + While there are plugins out there that can already do this, it would be nice for those to be available along with the additional features this plugin provides. + +- [ ] **A poll/voting system?**: + A bit of a far-fetched idea, but since changelogs include a lot of, well, *change*, they can sometimes be controversial along players. + I feel like a voting system might fit this plugin kind of well, so players can immediately share their opinion on a specific change right when they see it! + +> Contributions over on [GitHub](https://github.com/codingcat2468/ServerChangelogs) are always welcome, so if you do want to help out, it'd be nice to see if you are interested in implementing/helping with some of the above ideas! diff --git a/base/build.gradle.kts b/base/build.gradle.kts deleted file mode 100644 index f397b95..0000000 --- a/base/build.gradle.kts +++ /dev/null @@ -1,20 +0,0 @@ -plugins { - `java-library` - alias(libs.plugins.lombok) -} - -dependencies { - implementation(project(":platformapi")) - compileOnly(libs.snakeyaml) - compileOnly(libs.adventure.api) - compileOnly(libs.adventure.slf4j) - compileOnly(libs.mojang.brigadier) - compileOnly(libs.slf4j.api) - compileOnly(libs.adventure.minimessage) - compileOnly(libs.adventure.gson) - compileOnly(libs.packetevents.api) -} - -java { - toolchain.languageVersion = JavaLanguageVersion.of(25) -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/Entrypoints.java b/base/src/main/java/com/codingcat/changelogs/base/Entrypoints.java deleted file mode 100644 index f535558..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/Entrypoints.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.codingcat.changelogs.base; - -import com.codingcat.changelogs.platformapi.ChangelogsPlatform; -import com.codingcat.changelogs.platformapi.Entrypoint; -import org.jetbrains.annotations.NotNull; - -public final class Entrypoints { - public static @NotNull Entrypoint create(@NotNull ChangelogsPlatform platform) { - return new ServerChangelogs(platform); - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/ServerChangelogs.java b/base/src/main/java/com/codingcat/changelogs/base/ServerChangelogs.java deleted file mode 100644 index d012551..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/ServerChangelogs.java +++ /dev/null @@ -1,153 +0,0 @@ -package com.codingcat.changelogs.base; - -import com.codingcat.changelogs.base.command.BrigadierCommandNode; -import com.codingcat.changelogs.base.command.DialogSubCommands; -import com.codingcat.changelogs.base.compat.PacketEventsFix; -import com.codingcat.changelogs.base.config.PluginConfig; -import com.codingcat.changelogs.base.data.ChangelogStorage; -import com.codingcat.changelogs.base.dialog.DialogSessionManager; -import com.codingcat.changelogs.base.dialog.IDialog; -import com.codingcat.changelogs.base.event.ChangelogJoinListener; -import com.codingcat.changelogs.base.lang.TranslationSource; -import com.codingcat.changelogs.base.util.ResourceUtil; -import com.codingcat.changelogs.platformapi.ChangelogsPlatform; -import com.codingcat.changelogs.platformapi.Entrypoint; -import com.codingcat.changelogs.platformapi.command.ICommandManager; -import com.codingcat.changelogs.platformapi.event.IEventManager; -import com.mojang.brigadier.tree.LiteralCommandNode; -import lombok.Getter; -import net.kyori.adventure.key.Key; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.ComponentLike; -import net.kyori.adventure.text.logger.slf4j.ComponentLogger; -import net.kyori.adventure.translation.GlobalTranslator; -import org.jetbrains.annotations.NotNull; -import org.yaml.snakeyaml.error.YAMLException; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.Locale; -import java.util.Objects; -import java.util.Set; -import java.util.function.Function; - -import static com.codingcat.changelogs.base.lang.TranslationSource.translatable; -import static com.mojang.brigadier.builder.LiteralArgumentBuilder.literal; -import static net.kyori.adventure.text.Component.text; - -public final class ServerChangelogs extends Entrypoint { - public static @NotNull String NAMESPACE = "server_changelogs"; - @SuppressWarnings("PatternValidation") - public static final Function KEY_GENERATOR = path -> Key.key(NAMESPACE, path); - private static ComponentLogger logger; - private TranslationSource translationSource; - private PluginConfig config; - private ChangelogJoinListener joinListener; - private @Getter ChangelogStorage changelogStorage; - private @Getter IDialog.Holder dialogHolder; - private @Getter DialogSessionManager dialogSessionManager; - - public ServerChangelogs(@NotNull ChangelogsPlatform platform) { - super(platform); - } - - @Override - public void onStart() { - logger = getPlatform().getComponentLogger(); - Path translationPath = getPlatform().getDataPath().resolve("lang"); - if (translationPath.toFile().mkdirs()) { - logger.info("Creating default translation files..."); - ResourceUtil.readResourcesAsString("lang").forEach((fname, contents) -> { - try { - Files.writeString(translationPath.resolve(fname), contents, StandardCharsets.UTF_8, StandardOpenOption.CREATE); - } catch (IOException e) { - logger.warn("Failed to create default translation file \"{}\":", fname, e); - } - }); - } - this.translationSource = new TranslationSource(translationPath, getPlatform().getChangelogsMeta(), getPlatform().getPlatformMeta(), logger); - this.translationSource.reload(); - info("console.startup"); - Path configPath = getPlatform().getDataPath().resolve("config.yml"); - if (!configPath.toFile().exists()) { - logger.info("Creating default configuration file..."); - String defaultConfig = ResourceUtil.readResourceAsString("defaults/config.yml"); - try { - Files.writeString(configPath, defaultConfig, StandardCharsets.UTF_8, StandardOpenOption.CREATE); - } catch (IOException e) { - logger.warn("Failed to create default config file \"{}\":", configPath, e); - } - } - this.config = new PluginConfig(this, configPath); - this.config.tryReload(); - this.changelogStorage = this.config.createChangelogStorage(); - info("console.startup_storage", text(this.changelogStorage.getDisplayName())); - this.changelogStorage.init(); - PacketEventsFix.setManualWorkarounds(config.getEnabledManualWorkarounds(), logger); - this.dialogHolder = new IDialog.Holder(this); - this.dialogHolder.recreate(); - this.dialogSessionManager = new DialogSessionManager(this.dialogHolder, getPlatform().getPlayerManager()); - this.dialogSessionManager.registerEvents(); - this.joinListener = new ChangelogJoinListener(this::getChangelogStorage, dialogHolder, dialogSessionManager, config); - this.joinListener.registerEvents(getPlatform().getEventManager()); - this.registerCommands(getPlatform().getCommandManager()); - } - - private void registerCommands(@NotNull ICommandManager commandManager) { - LiteralCommandNode rootNode = literal(NAMESPACE).build(); - BrigadierCommandNode.SUB_COMMANDS.forEach(c -> rootNode.addChild(c.build(this))); - commandManager.register(rootNode, Set.of("changelogs", "scl")); - if (this.config.registerDedicatedCommand()) - commandManager.register(DialogSubCommands.buildDedicatedChangelogCommand(this)); - } - - public void reload(boolean force) throws IOException, YAMLException, IDialog.DestroyRejectedException { - info("console.reload"); - if (!force) this.dialogHolder.ensureCanReload(); - this.translationSource.reload(); - this.config.reload(); - String err = this.config.validate(); - if (err != null) throw new YAMLException(err); - this.changelogStorage.shutdown(); - this.changelogStorage = this.config.createChangelogStorage(); - info("console.startup_storage", text(this.changelogStorage.getDisplayName())); - this.changelogStorage.init(); - PacketEventsFix.setManualWorkarounds(config.getEnabledManualWorkarounds(), logger); - this.dialogHolder.recreate(); - IEventManager eventManager = getPlatform().getEventManager(); - this.joinListener.unregisterEvents(eventManager); - this.joinListener.registerEvents(eventManager); - } - - @Override - public void onShutdown() { - info("console.shutdown"); - if (this.dialogSessionManager != null) this.dialogSessionManager.unregisterEvents(); - if (this.joinListener != null) this.joinListener.unregisterEvents(getPlatform().getEventManager()); - if (this.changelogStorage != null) this.changelogStorage.shutdown(); - } - - public @NotNull PluginConfig pluginConfig() { - return this.config; - } - - public static void info(@NotNull String key, @NotNull ComponentLike... args) { - logger.info(translateConsole(key, args)); - } - - public static void warn(@NotNull String key, @NotNull ComponentLike... args) { - logger.warn(translateConsole(key, args)); - } - - public static void error(@NotNull String key, @NotNull Throwable e, @NotNull ComponentLike... args) { - logger.error(translateConsole(key, args), e); - } - - private static @NotNull Component translateConsole(@NotNull String key, @NotNull ComponentLike... args) { - Component value = GlobalTranslator.translator().translate(translatable(key, args), Locale.of("en_US")); - return Objects.requireNonNullElseGet(value, () -> text(key)); - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/command/BrigadierCommandNode.java b/base/src/main/java/com/codingcat/changelogs/base/command/BrigadierCommandNode.java deleted file mode 100644 index 80a7ef5..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/command/BrigadierCommandNode.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.codingcat.changelogs.base.command; - -import com.codingcat.changelogs.base.ServerChangelogs; -import com.codingcat.changelogs.platformapi.command.ICommandManager; -import com.codingcat.changelogs.platformapi.player.IPlayer; -import com.mojang.brigadier.tree.LiteralCommandNode; -import org.jetbrains.annotations.NotNull; - -import java.util.Set; -import java.util.function.Predicate; - -public interface BrigadierCommandNode { - @NotNull Set SUB_COMMANDS = DialogSubCommands.withDialogs(new ReloadSubCommand(), new InfoSubCommand()); - - @NotNull LiteralCommandNode build(@NotNull ServerChangelogs plugin); - - static @NotNull Predicate requirePermission(@NotNull String permission, @NotNull ServerChangelogs plugin, boolean trueIfUnset) { - ICommandManager commandManager = plugin.getPlatform().getCommandManager(); - if (plugin.pluginConfig().useNativeFallbackPermissions()) return source -> { - IPlayer player = commandManager.adaptPlatformSource(source).getExecutingPlayer(); - if (player == null) return true; - return player.isNativeAdmin().toBooleanOrElse(false) || trueIfUnset; - }; - String perm = ServerChangelogs.NAMESPACE + "." + permission; - return source -> commandManager.adaptPlatformSource(source).hasPermission(perm, trueIfUnset); - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/command/DialogSubCommands.java b/base/src/main/java/com/codingcat/changelogs/base/command/DialogSubCommands.java deleted file mode 100644 index 0b06096..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/command/DialogSubCommands.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.codingcat.changelogs.base.command; - -import com.codingcat.changelogs.base.ServerChangelogs; -import com.codingcat.changelogs.base.dialog.DialogPackets; -import com.codingcat.changelogs.base.dialog.ui.ChangelogDialog; -import com.codingcat.changelogs.base.dialog.ui.editor.ChangelogEditorDialog; -import com.codingcat.changelogs.base.dialog.IDialog; -import com.codingcat.changelogs.platformapi.command.ICommandManager; -import com.codingcat.changelogs.platformapi.command.ICommandSource; -import com.mojang.brigadier.tree.LiteralCommandNode; -import lombok.AccessLevel; -import lombok.RequiredArgsConstructor; -import org.jetbrains.annotations.NotNull; - -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - -import static com.codingcat.changelogs.base.lang.TranslationSource.translatable; -import static com.mojang.brigadier.builder.LiteralArgumentBuilder.literal; - -public class DialogSubCommands { - public static @NotNull Set withDialogs(@NotNull BrigadierCommandNode... commands) { - Set commandSet = new HashSet<>(); - commandSet.add(new Command("create", "command.create", false, ChangelogEditorDialog.class)); - commandSet.add(new Command("view", "command.view", true, ChangelogDialog.class)); - commandSet.addAll(Arrays.stream(commands).toList()); - return commandSet; - } - - public static @NotNull LiteralCommandNode buildDedicatedChangelogCommand(@NotNull ServerChangelogs plugin) { - return new Command("changelog", "dedicated_command", true, ChangelogDialog.class).build(plugin); - } - - @RequiredArgsConstructor(access = AccessLevel.PRIVATE) - private static class Command implements BrigadierCommandNode { - private final @NotNull String name; - private final @NotNull String permission; - private final boolean permissionEnabledByDefault; - private final @NotNull Class dialogCls; - - @Override - public @NotNull LiteralCommandNode build(@NotNull ServerChangelogs plugin) { - ICommandManager commandManager = plugin.getPlatform().getCommandManager(); - return literal(this.name) - .requires(BrigadierCommandNode.requirePermission(this.permission, plugin, permissionEnabledByDefault)) - .executes(ctx -> { - ICommandSource source = commandManager.adaptPlatformSource(ctx.getSource()); - if (source.getExecutingPlayer() == null) { - source.asAudience().sendMessage(translatable("command.onlyplayer")); - return com.mojang.brigadier.Command.SINGLE_SUCCESS; - } - plugin.getDialogHolder().getFromType(this.dialogCls) - .showTo(source.getExecutingPlayer(), plugin.getDialogSessionManager(), DialogPackets.PacketPhase.PLAY); - return com.mojang.brigadier.Command.SINGLE_SUCCESS; - }).build(); - } - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/command/InfoSubCommand.java b/base/src/main/java/com/codingcat/changelogs/base/command/InfoSubCommand.java deleted file mode 100644 index e178483..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/command/InfoSubCommand.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.codingcat.changelogs.base.command; - -import com.codingcat.changelogs.base.ServerChangelogs; -import com.codingcat.changelogs.platformapi.command.ICommandManager; -import com.mojang.brigadier.Command; -import com.mojang.brigadier.tree.LiteralCommandNode; -import org.jetbrains.annotations.NotNull; - -import static com.codingcat.changelogs.base.lang.TranslationSource.translatable; -import static com.mojang.brigadier.builder.LiteralArgumentBuilder.literal; - -public class InfoSubCommand implements BrigadierCommandNode { - @Override - public @NotNull LiteralCommandNode build(@NotNull ServerChangelogs plugin) { - ICommandManager commandManager = plugin.getPlatform().getCommandManager(); - return literal("info") - .requires(BrigadierCommandNode.requirePermission("command.info", plugin, false)) - .executes(ctx -> { - commandManager.adaptPlatformSource(ctx.getSource()).asAudience().sendMessage(translatable("command.root")); - return Command.SINGLE_SUCCESS; - }).build(); - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/command/ReloadSubCommand.java b/base/src/main/java/com/codingcat/changelogs/base/command/ReloadSubCommand.java deleted file mode 100644 index c98bba4..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/command/ReloadSubCommand.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.codingcat.changelogs.base.command; - -import com.codingcat.changelogs.base.ServerChangelogs; -import com.codingcat.changelogs.base.dialog.IDialog; -import com.codingcat.changelogs.platformapi.command.ICommandManager; -import com.mojang.brigadier.Command; -import com.mojang.brigadier.context.CommandContext; -import com.mojang.brigadier.tree.LiteralCommandNode; -import net.kyori.adventure.audience.Audience; -import net.kyori.adventure.text.Component; -import org.jetbrains.annotations.NotNull; -import org.yaml.snakeyaml.error.YAMLException; - -import java.io.IOException; - -import static com.codingcat.changelogs.base.ServerChangelogs.error; -import static com.codingcat.changelogs.base.lang.TranslationSource.translatable; -import static com.mojang.brigadier.builder.LiteralArgumentBuilder.literal; -import static net.kyori.adventure.text.Component.text; - -public class ReloadSubCommand implements BrigadierCommandNode { - @Override - public @NotNull LiteralCommandNode build(@NotNull ServerChangelogs plugin) { - return literal("reload") - .requires(BrigadierCommandNode.requirePermission("command.reload", plugin, false)) - .executes(ctx -> execute(ctx, plugin, false)) - .then(literal("--force").executes(ctx -> execute(ctx, plugin, true))) - .build(); - } - - private int execute(@NotNull CommandContext ctx, @NotNull ServerChangelogs plugin, boolean force) { - ICommandManager commandManager = plugin.getPlatform().getCommandManager(); - Audience audience = commandManager.adaptPlatformSource(ctx.getSource()).asAudience(); - long time = System.currentTimeMillis(); - Component errorMsg = null; - try { - plugin.reload(force); - long took = System.currentTimeMillis() - time; - audience.sendMessage(translatable("command.reload.success", text(took))); - } catch (IOException e) { - errorMsg = translatable("command.reload.error.io"); - error("command.reload.error.details", e); - } catch (YAMLException e) { - errorMsg = translatable("command.reload.error.invalid", text(e.getMessage())); - } catch (IDialog.DestroyRejectedException e) { - errorMsg = translatable("command.reload.error.dialog_reject." + e.getKey()); - } - if (errorMsg != null) audience.sendMessage(errorMsg); - return Command.SINGLE_SUCCESS; - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/compat/PacketEventsFix.java b/base/src/main/java/com/codingcat/changelogs/base/compat/PacketEventsFix.java deleted file mode 100644 index 0f9902b..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/compat/PacketEventsFix.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.codingcat.changelogs.base.compat; - -import com.github.retrooper.packetevents.PacketEvents; -import com.github.retrooper.packetevents.manager.server.ServerVersion; -import com.github.retrooper.packetevents.protocol.component.ComponentTypes; -import com.github.retrooper.packetevents.protocol.component.PatchableComponentMap; -import com.github.retrooper.packetevents.protocol.component.builtin.item.FoodProperties; -import com.github.retrooper.packetevents.protocol.dialog.body.ItemDialogBody; -import com.github.retrooper.packetevents.util.PEVersion; -import lombok.RequiredArgsConstructor; -import net.kyori.adventure.text.logger.slf4j.ComponentLogger; -import org.jetbrains.annotations.NotNull; - -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.function.BiPredicate; - -/** - * This class introduces fixes for versions of PacketEvents that cause problems with the dialog system. - */ -public final class PacketEventsFix { - private static @NotNull Set enabledWorkarounds = new HashSet<>(); - - @RequiredArgsConstructor - public enum Workaround { - // https://github.com/retrooper/packetevents/issues/1569 - until v2.13.0 on MC versions < 26.1 - INVALID_ITEM_BODY_ENCODING((p, s) -> p.isOlderThan(new PEVersion(2, 13, 1)) && s.isOlderThan(ServerVersion.V_26_1)); - - private final @NotNull BiPredicate versionPredicate; - - public boolean isAffected() { - PEVersion peVersion = PacketEvents.getAPI().getVersion(); - ServerVersion serverVersion = PacketEvents.getAPI().getServerManager().getVersion(); - return this.versionPredicate.test(peVersion, serverVersion); - } - } - - public static void setManualWorkarounds(@NotNull Set manualWorkarounds, @NotNull ComponentLogger logger) { - checkAndLoad(logger); - enabledWorkarounds.addAll(manualWorkarounds); - if (!manualWorkarounds.isEmpty()) { - logger.warn("Manual workarounds for PacketEvents issues have been enabled. This may cause unintended behavior if activated by accident!"); - List workaroundNames = manualWorkarounds.stream().map(Enum::name).toList(); - logger.warn("Enabled manual workarounds: {}", String.join(", ", workaroundNames)); - } - } - - public static void checkAndLoad(@NotNull ComponentLogger logger) { - enabledWorkarounds = Arrays.stream(Workaround.values()) - .filter(Workaround::isAffected) - .collect(HashSet::new, Set::add, HashSet::addAll); - if (!enabledWorkarounds.isEmpty()) { - logger.warn("You are using a version of PacketEvents that contains known problems. The plugin will automatically work around those issues, but updating to the latest version is recommended!"); - List workaroundNames = enabledWorkarounds.stream().map(Enum::name).toList(); - logger.warn("Enabled workarounds: {}", String.join(", ", workaroundNames)); - } - } - - public static boolean isEnabled(@NotNull Workaround workaround) { - return enabledWorkarounds.contains(workaround); - } - - @SuppressWarnings("deprecation") - public static void fixItemBody(@NotNull ItemDialogBody itemBody) { - if (!isEnabled(Workaround.INVALID_ITEM_BODY_ENCODING)) return; - PatchableComponentMap componentMap = itemBody.getItem().getComponents(); - if (!componentMap.getPatches().isEmpty()) return; - // Add dummy component data which won't affect the item visually - componentMap.set(ComponentTypes.FOOD, new FoodProperties(123, 123f, false)); - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/compat/PacketEventsNativeItemManager.java b/base/src/main/java/com/codingcat/changelogs/base/compat/PacketEventsNativeItemManager.java deleted file mode 100644 index 563e6f6..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/compat/PacketEventsNativeItemManager.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.codingcat.changelogs.base.compat; - -import com.codingcat.changelogs.platformapi.item.NativeItemManager; -import com.github.retrooper.packetevents.protocol.item.ItemStack; -import com.github.retrooper.packetevents.protocol.item.type.ItemType; -import com.github.retrooper.packetevents.protocol.item.type.ItemTypes; -import com.github.retrooper.packetevents.resources.ResourceLocation; -import net.kyori.adventure.key.Key; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -public class PacketEventsNativeItemManager implements NativeItemManager { - @Override - public @NotNull ItemStack adaptToPEStack(@NotNull Object nativeStack) throws IllegalArgumentException { - if (!(nativeStack instanceof ItemStack stack)) - throw new IllegalArgumentException("Expected PacketEvents ItemStack but got" + nativeStack); - return stack; - } - - @Override - public @Nullable Object createNativeStack(@NotNull Key key, int amount) { - ItemType type = ItemTypes.getRegistry().getByName(new ResourceLocation(key)); - return type != null ? ItemStack.builder().type(type).amount(amount).build() : null; - } - - @Override - public @NotNull Object applyComponentStr(@NotNull Object nativeStack, @NotNull String componentStr) { - // TODO: Implement component parsing - return nativeStack; - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/config/PluginConfig.java b/base/src/main/java/com/codingcat/changelogs/base/config/PluginConfig.java deleted file mode 100644 index 12ecf98..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/config/PluginConfig.java +++ /dev/null @@ -1,134 +0,0 @@ -package com.codingcat.changelogs.base.config; - -import com.codingcat.changelogs.base.ServerChangelogs; -import com.codingcat.changelogs.base.compat.PacketEventsFix; -import com.codingcat.changelogs.base.data.ChangelogStorage; -import com.codingcat.changelogs.base.dialog.DialogPackets; -import com.codingcat.changelogs.platformapi.item.NativeItemManager; -import com.github.retrooper.packetevents.protocol.item.ItemStack; -import lombok.RequiredArgsConstructor; -import net.kyori.adventure.key.InvalidKeyException; -import net.kyori.adventure.key.Key; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.yaml.snakeyaml.Yaml; -import org.yaml.snakeyaml.error.YAMLException; - -import java.io.FileInputStream; -import java.io.IOException; -import java.nio.file.Path; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.*; -import java.util.stream.Collectors; - -@RequiredArgsConstructor -public class PluginConfig extends Yaml { - private final @NotNull ServerChangelogs plugin; - private final @NotNull Path path; - private @NotNull Map data = new HashMap<>(); - - public void tryReload() { - try { - this.reload(); - } catch (IOException | YAMLException e) { - throw new RuntimeException("Failed to reload configuration", e); - } - String err = this.validate(); - if (err != null) throw new RuntimeException("Configuration invalid: " + err); - } - - public void reload() throws IOException, YAMLException { - this.data = this.load(new FileInputStream(this.path.toFile())); - } - - public @Nullable String validate() { - try { - getDialogPacketPhase(); - createChangelogStorage(); - getDateFormatter(); - createChangelogHeaderStack(); - getEnabledManualWorkarounds(); - return null; - } catch (Exception e) { - return e.getMessage(); - } - } - - public @NotNull ChangelogStorage createChangelogStorage() throws RuntimeException { - return ChangelogStorage.create(getString("changelog_storage", "yaml"), this.plugin); - } - - public @NotNull DateTimeFormatter getDateFormatter() { - return DateTimeFormatter.ofPattern(getString("date_format", "----")) - .withZone(ZoneId.of(getString("date_timezone", "UTC"))); - } - - public boolean registerDedicatedCommand() { - return getBoolean("register_dedicated_command", true); - } - - public @NotNull DialogPackets.PacketPhase getDialogPacketPhase() { - String rawPhase = getString("dialog_phase", DialogPackets.PacketPhase.PLAY.name()); - try { - return DialogPackets.PacketPhase.valueOf(rawPhase.toUpperCase(Locale.ROOT)); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Invalid dialog phase \"" + rawPhase + "\""); - } - } - - public @NotNull Set getEnabledManualWorkarounds() { - List rawWorkarounds = getList("enable_manual_workarounds", String.class); - return rawWorkarounds.stream().map(workaround -> { - try { - return PacketEventsFix.Workaround.valueOf(workaround.toUpperCase(Locale.ROOT)); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Invalid manual workaround ID \"" + workaround + "\""); - } - }).collect(Collectors.toUnmodifiableSet()); - } - - public boolean showChangelogHeader() { - return getBoolean("dialog_header", true); - } - - public boolean useNativeFallbackPermissions() { - return getBoolean("use_native_fallback_permissions", false); - } - - public @Nullable ItemStack createChangelogHeaderStack() { - String value = getString("dialog_header_item", null); - return value != null ? createStack(value) : null; - } - - private String getString(@NotNull String key, @Nullable String defaultValue) { - return (String) this.data.getOrDefault(key, defaultValue); - } - - private boolean getBoolean(@NotNull String key, boolean defaultValue) { - return (boolean) this.data.getOrDefault(key, defaultValue); - } - - private @NotNull List getList(@NotNull String key, @NotNull Class cls) { - List list = (List) this.data.get(key); - if (list == null) return List.of(); - return list.stream().map(cls::cast).toList(); - } - - @SuppressWarnings("PatternValidation") - private @NotNull ItemStack createStack(@NotNull String input) { - String idPart = input.contains("[") ? input.substring(0, input.indexOf('[')) : input; - String componentPart = input.contains("[") ? input.substring(input.indexOf('[')) : null; - Key inputKey; - try { - inputKey = Key.key(idPart); - } catch (InvalidKeyException e) { - throw new RuntimeException("Invalid item ID \"" + idPart + "\"", e); - } - NativeItemManager nativeManager = this.plugin.getPlatform().getNativeItemManager(); - Object nativeStack = nativeManager.createNativeStack(inputKey, 1); - Objects.requireNonNull(nativeStack, "Unknown item ID \"" + idPart + "\""); - if (componentPart != null) nativeStack = nativeManager.applyComponentStr(nativeStack, componentPart); - return (ItemStack) nativeManager.adaptToPEStack(nativeStack); - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/data/ChangelogEntry.java b/base/src/main/java/com/codingcat/changelogs/base/data/ChangelogEntry.java deleted file mode 100644 index 1d795e1..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/data/ChangelogEntry.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.codingcat.changelogs.base.data; - -import com.codingcat.changelogs.platformapi.player.IPlayer; -import net.kyori.adventure.text.Component; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.time.Instant; -import java.util.List; -import java.util.Set; -import java.util.UUID; - -public record ChangelogEntry( - int uid, - @NotNull List lines, - @NotNull Instant recordedAt, - @Nullable Component author, - @NotNull Set playersRead -) { - public boolean hasRead(@NotNull IPlayer player) { - return this.playersRead().contains(player.getUniqueId()); - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/data/ChangelogStorage.java b/base/src/main/java/com/codingcat/changelogs/base/data/ChangelogStorage.java deleted file mode 100644 index 5facc95..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/data/ChangelogStorage.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.codingcat.changelogs.base.data; - -import com.codingcat.changelogs.base.ServerChangelogs; -import com.codingcat.changelogs.base.data.storage.YamlChangelogStorage; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.List; -import java.util.UUID; - -public interface ChangelogStorage { - void init(); - - void shutdown(); - - void storeEntry(@NotNull ChangelogEntry entry); - - void updateEntry(@NotNull ChangelogEntry entry); - - boolean removeEntry(int uid); - - @NotNull List listEntries(); - - @Nullable ChangelogEntry getByUID(int uid); - - void markAsRead(int uid, @NotNull UUID player); - - int nextUID(); - - @NotNull String getDisplayName(); - - static @NotNull ChangelogStorage create(@NotNull String identifier, @NotNull ServerChangelogs plugin) throws IllegalArgumentException { - if (!identifier.equals("yaml")) - throw new IllegalArgumentException("Unknown changelog storage type \"" + identifier + "\""); - return new YamlChangelogStorage(plugin.getPlatform().getDataPath().resolve("_data.yml")); - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.java b/base/src/main/java/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.java deleted file mode 100644 index 945aa3a..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.java +++ /dev/null @@ -1,181 +0,0 @@ -package com.codingcat.changelogs.base.data.storage; - -import com.codingcat.changelogs.base.data.ChangelogEntry; -import com.codingcat.changelogs.base.data.ChangelogStorage; -import lombok.RequiredArgsConstructor; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.yaml.snakeyaml.Yaml; -import org.yaml.snakeyaml.error.YAMLException; - -import java.io.*; -import java.nio.file.Path; -import java.time.Instant; -import java.time.format.DateTimeFormatter; -import java.util.*; -import java.util.stream.Collectors; - -@RequiredArgsConstructor -public class YamlChangelogStorage implements ChangelogStorage { - private final @NotNull Path filePath; - private List cache; - - @Override - public void init() { - if (!filePath.toFile().exists()) { - this.cache = new ArrayList<>(); - this.save(); - return; - } - try { - Yaml config = new Yaml(); - Map data = config.load(new FileInputStream(filePath.toFile())); - this.cache = this.deserializeEntries(data); - } catch (IOException | YAMLException e) { - throw new RuntimeException("Failed to load changelog data from " + filePath, e); - } - } - - @Override - public void shutdown() { - } - - @Override - public void storeEntry(@NotNull ChangelogEntry entry) { - this.cache.add(entry.uid(), entry); - this.save(); - } - - @Override - public void updateEntry(@NotNull ChangelogEntry entry) { - this.cache.set(entry.uid(), entry); - this.save(); - } - - @Override - public boolean removeEntry(int uid) { - boolean success = this.cache.removeIf(e -> e.uid() == uid); - if (success) { - this.fixEntryUIDs(); - this.save(); - } - return success; - } - - private void save() { - Yaml config = new Yaml(); - List> data = this.cache.stream() - .map(this::serializeEntry) - .toList(); - try (FileOutputStream os = new FileOutputStream(filePath.toFile())) { - config.dump(Map.of("entries", data), new BufferedWriter(new OutputStreamWriter(os))); - } catch (IOException e) { - throw new RuntimeException("Failed to save changelog data to " + filePath, e); - } - } - - private @NotNull List deserializeEntries(@NotNull Map config) { - try { - //noinspection unchecked - List> entries = (List>) config.get("entries"); - Objects.requireNonNull(entries, "entries"); - List results = new ArrayList<>(); - for (int i = 0; i < entries.size(); i++) { - //noinspection unchecked - Map data = (Map) entries.get(i); - results.add(this.deserializeEntry(i, data)); - } - return results; - } catch (Exception e) { - throw new RuntimeException("Failed to deserialize changelog entries", e); - } - } - - private @NotNull ChangelogEntry deserializeEntry(int uid, @NotNull Map data) { - //noinspection unchecked - List lines = ((List) data.get("serializedLines")) - .stream().map(GsonComponentSerializer.gson()::deserialize) - .toList(); - String recordedAtRaw = Objects.requireNonNull((String) data.get("recordedAt"), "recordedAt"); - Instant recordedAt = DateTimeFormatter.ISO_INSTANT.parse(recordedAtRaw, Instant::from); - Component author = Optional.ofNullable((String) data.get("author")) - .map(GsonComponentSerializer.gson()::deserialize) - .orElse(null); - //noinspection unchecked - Set playersRead = ((List) data.get("playersRead")) - .stream().map(UUID::fromString) - .collect(Collectors.toSet()); - return new ChangelogEntry(uid, lines, recordedAt, author, playersRead); - } - - private @NotNull Map serializeEntry(@NotNull ChangelogEntry entry) { - Map data = new HashMap<>(); - List serializedLines = entry.lines() - .stream().map(GsonComponentSerializer.gson()::serialize) - .toList(); - data.put("serializedLines", serializedLines); - String recordedAtRaw = DateTimeFormatter.ISO_INSTANT.format(entry.recordedAt()); - data.put("recordedAt", recordedAtRaw); - String serializedAuthor = Optional.ofNullable(entry.author()) - .map(GsonComponentSerializer.gson()::serialize) - .orElse(null); - data.put("author", serializedAuthor); - List rawPlayersRead = entry.playersRead() - .stream().map(UUID::toString).toList(); - data.put("playersRead", rawPlayersRead); - return data; - } - - @Override - public @NotNull List listEntries() { - return this.cache; - } - - @Override - public @Nullable ChangelogEntry getByUID(int uid) { - try { - return this.cache.get(uid); - } catch (IndexOutOfBoundsException _) { - return null; - } - } - - @Override - public void markAsRead(int uid, @NotNull UUID player) { - this.cache.stream() - .filter(e -> e.uid() == uid) - .forEach(e -> e.playersRead().add(player)); - this.save(); - } - - @Override - public int nextUID() { - return this.cache.size(); - } - - /** - * Fix the UIDs of cache entries desyncing from their actual - * position in the list when {@link #removeEntry(int)} is called. - * This re-creates the entire cache including all of its entries. - */ - private void fixEntryUIDs() { - List entries = new ArrayList<>(this.cache); - this.cache.clear(); - for (int i = 0; i < entries.size(); i++) { - ChangelogEntry original = entries.get(i); - this.cache.add(new ChangelogEntry( - i, original.lines(), - original.recordedAt(), - original.author(), - original.playersRead() - )); - } - } - - @Override - public @NotNull String getDisplayName() { - return "YAML File"; - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/dialog/DialogPackets.java b/base/src/main/java/com/codingcat/changelogs/base/dialog/DialogPackets.java deleted file mode 100644 index 993effe..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/dialog/DialogPackets.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.codingcat.changelogs.base.dialog; - -import com.codingcat.changelogs.base.dialog.ui.ChangelogDialog; -import com.codingcat.changelogs.platformapi.player.IPlayer; -import com.github.retrooper.packetevents.protocol.dialog.*; -import com.github.retrooper.packetevents.protocol.dialog.action.Action; -import com.github.retrooper.packetevents.protocol.dialog.body.DialogBody; -import com.github.retrooper.packetevents.protocol.dialog.body.PlainMessage; -import com.github.retrooper.packetevents.protocol.dialog.body.PlainMessageDialogBody; -import com.github.retrooper.packetevents.protocol.dialog.button.ActionButton; -import com.github.retrooper.packetevents.protocol.dialog.button.CommonButtonData; -import com.github.retrooper.packetevents.protocol.player.User; -import com.github.retrooper.packetevents.wrapper.PacketWrapper; -import com.github.retrooper.packetevents.wrapper.configuration.server.WrapperConfigServerClearDialog; -import com.github.retrooper.packetevents.wrapper.configuration.server.WrapperConfigServerShowDialog; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerClearDialog; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerShowDialog; -import lombok.RequiredArgsConstructor; -import net.kyori.adventure.audience.Audience; -import net.kyori.adventure.permission.PermissionChecker; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.util.TriState; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.List; -import java.util.Locale; -import java.util.UUID; - -import static com.codingcat.changelogs.base.lang.TranslationSource.translatableManual; - -public final class DialogPackets { - public static void showDialog(@NotNull IPlayer player, @NotNull Dialog dialog, @NotNull PacketPhase packetPhase) { - packet(player, packetPhase == PacketPhase.CONFIGURATION ? new WrapperConfigServerShowDialog(dialog) : new WrapperPlayServerShowDialog(dialog)); - } - - public static void showSimpleNotice(@NotNull IPlayer player, @NotNull String titleTranslation, @NotNull String bodyTranslation) { - showSimpleNotice(player, titleTranslation, bodyTranslation, null, PacketPhase.PLAY); - } - - public static void showSimpleConfirm(@NotNull IPlayer player, @NotNull String titleTranslation, @NotNull String bodyTranslation, @NotNull Action confirm, boolean confirmDanger, @NotNull Action cancel, @NotNull PacketPhase packetPhase) { - CommonDialogData common = createSimpleDialog(player, titleTranslation, bodyTranslation, false, false); - ActionButton confirmButton = new ActionButton(new CommonButtonData(translatableManual(player, "dialog.popup.confirm" + (confirmDanger ? "_danger" : "")), null, 100), confirm); - ActionButton cancelButton = new ActionButton(new CommonButtonData(translatableManual(player, "dialog.popup.cancel"), null, 100), cancel); - showDialog(player, new MultiActionDialog(common, List.of(confirmButton, cancelButton), null, 2), packetPhase); - } - - public static void showSimpleNotice(@NotNull IPlayer player, @NotNull String titleTranslation, @NotNull String bodyTranslation, @Nullable Action action, @NotNull PacketPhase packetPhase) { - CommonDialogData common = createSimpleDialog(player, titleTranslation, bodyTranslation, action == null, true); - ActionButton closeButton = new ActionButton(new CommonButtonData(translatableManual(player, "dialog.popup.close"), null, 40), action); - showDialog(player, new NoticeDialog(common, closeButton), packetPhase); - } - - public static @NotNull CommonDialogData createSimpleDialog(@NotNull IPlayer player, @NotNull String titleTranslation, @NotNull String bodyTranslation, boolean closeAfter, boolean canEscape) { - DialogBody body = new PlainMessageDialogBody(new PlainMessage(translatableManual(player, bodyTranslation), ChangelogDialog.LINE_WIDTH)); - return new CommonDialogData( - translatableManual(player, titleTranslation), - null, canEscape, false, - closeAfter ? DialogAction.CLOSE : DialogAction.NONE, List.of(body), List.of() - ); - } - - public static void clearDialog(@NotNull IPlayer player, @NotNull PacketPhase packetPhase) { - packet(player, packetPhase == PacketPhase.CONFIGURATION ? new WrapperConfigServerClearDialog() : new WrapperPlayServerClearDialog()); - } - - public static @NotNull IPlayer wrapPacketEventsUser(@NotNull User user) { - return new PacketEventsUserWrappedPlayer(user); - } - - private static void packet(@NotNull IPlayer player, @NotNull PacketWrapper wrapper) { - User user = (User) player.asPacketEventsUser(); - user.sendPacket(wrapper); - } - - public enum PacketPhase { - CONFIGURATION, - PLAY - } - - @RequiredArgsConstructor - private static class PacketEventsUserWrappedPlayer implements IPlayer { - private static final @NotNull RuntimeException EXCEPTION = new UnsupportedOperationException("Not supported on PacketEvents user wrapper"); - private final @NotNull User user; - - @Override - public @NotNull UUID getUniqueId() { - return this.user.getUUID(); - } - - @Override - public @NotNull Locale getClientLocale() { - throw EXCEPTION; - } - - @Override - public boolean isFirstJoin() { - throw EXCEPTION; - } - - @Override - public @NotNull TriState isNativeAdmin() { - return TriState.NOT_SET; - } - - @Override - public void kick(@Nullable Component reason) { - throw EXCEPTION; - } - - @Override - public @NotNull Object asPacketEventsUser() { - return this.user; - } - - @Override - public @NotNull Audience asAudience() { - return Audience.empty(); - } - - @Override - public @NotNull PermissionChecker asPermissionChecker() { - return PermissionChecker.always(TriState.NOT_SET); - } - - @Override - public @NotNull Object unwrapNative() { - return this.user; - } - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/dialog/DialogSessionManager.java b/base/src/main/java/com/codingcat/changelogs/base/dialog/DialogSessionManager.java deleted file mode 100644 index a7269fe..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/dialog/DialogSessionManager.java +++ /dev/null @@ -1,190 +0,0 @@ -package com.codingcat.changelogs.base.dialog; - -import com.codingcat.changelogs.base.ServerChangelogs; -import com.codingcat.changelogs.platformapi.player.IPlayer; -import com.codingcat.changelogs.platformapi.player.IPlayerManager; -import com.github.retrooper.packetevents.PacketEvents; -import com.github.retrooper.packetevents.event.PacketListener; -import com.github.retrooper.packetevents.event.PacketListenerCommon; -import com.github.retrooper.packetevents.event.PacketListenerPriority; -import com.github.retrooper.packetevents.event.PacketReceiveEvent; -import com.github.retrooper.packetevents.protocol.chat.clickevent.CustomClickEvent; -import com.github.retrooper.packetevents.protocol.dialog.action.Action; -import com.github.retrooper.packetevents.protocol.dialog.action.DynamicCustomAction; -import com.github.retrooper.packetevents.protocol.dialog.action.StaticAction; -import com.github.retrooper.packetevents.protocol.nbt.NBTCompound; -import com.github.retrooper.packetevents.protocol.packettype.PacketType; -import com.github.retrooper.packetevents.protocol.util.NbtCodecException; -import com.github.retrooper.packetevents.resources.ResourceLocation; -import com.github.retrooper.packetevents.util.adventure.NbtTagHolder; -import com.github.retrooper.packetevents.wrapper.common.client.WrapperCommonClientCustomClickAction; -import com.github.retrooper.packetevents.wrapper.configuration.client.WrapperConfigClientCustomClickAction; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientCustomClickAction; -import lombok.RequiredArgsConstructor; -import net.kyori.adventure.key.Key; -import net.kyori.adventure.nbt.api.BinaryTagHolder; -import net.kyori.adventure.text.event.ClickEvent; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Map; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.CancellationException; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Function; -import java.util.function.Supplier; - -@RequiredArgsConstructor -public class DialogSessionManager implements PacketListener { - private static final @NotNull Function KEY_GENERATOR = path -> ServerChangelogs.KEY_GENERATOR.apply("dialog/" + path); - public static final @NotNull BinaryTagHolder NONE_TAG_HOLDER = BinaryTagHolder.binaryTagHolder(""); - private final Set staticActions = ConcurrentHashMap.newKeySet(); - private final Map activeSessions = new ConcurrentHashMap<>(); - private final Map sessionData = new ConcurrentHashMap<>(); - private final Map> frozenViewers = new ConcurrentHashMap<>(); - private final @NotNull IDialog.Holder dialogHolder; - private final @NotNull IPlayerManager playerManager; - private @Nullable PacketListenerCommon selfListener; - - public void handleCustomClick(@NotNull WrapperCommonClientCustomClickAction packetWrapper, @NotNull IPlayer player) { - Key key = packetWrapper.getId().key(); - int fSIdx, lSIdx; - if (!key.namespace().equals(ServerChangelogs.NAMESPACE) || (fSIdx = key.value().indexOf('/')) == -1 - || !key.value().startsWith("dialog/") || (fSIdx + 1) >= (lSIdx = key.value().lastIndexOf('/')) - || lSIdx == (key.value().length() - 1)) return; - String dialogId = key.value().substring(fSIdx + 1, lSIdx); - String actionId = key.value().substring(lSIdx + 1); - if (!dialogId.equals(activeSessions.get(player.getUniqueId())) && !staticActions.contains(key)) return; - IDialog dialog; - try { - dialog = this.dialogHolder.getFromId(dialogId); - } catch (NullPointerException e) { - return; - } - try { - dialog.onActionTriggered(actionId, (NBTCompound) packetWrapper.getPayload(), player, this); - } catch (NbtCodecException _) { - // Ignore invalid or unexpected NBT payloads like malicious packets sent by a modified client - } catch (Exception e) { - throw new RuntimeException("Failed to run dialog action handler for \"" + dialogId + "\", action \"" + actionId + "\"", e); - } - } - - @Override - public void onPacketReceive(@NotNull PacketReceiveEvent event) { - if (event.getPacketType() == PacketType.Play.Client.CUSTOM_CLICK_ACTION) - handleCustomClick(new WrapperPlayClientCustomClickAction(event), playerFromPacket(event)); - if (event.getPacketType() == PacketType.Configuration.Client.CUSTOM_CLICK_ACTION) - handleCustomClick(new WrapperConfigClientCustomClickAction(event), playerFromPacket(event)); - } - - private @NotNull IPlayer playerFromPacket(@NotNull PacketReceiveEvent event) { - try { - return this.playerManager.fromNative(event.getPlayer()); - } catch (IllegalArgumentException _) { - // If obtaining the native IPlayer implementation fails (e.g. during the configuration phase, - // where event.getPlayer() can return null), fall back to a wrapped PE User implementation. - return DialogPackets.wrapPacketEventsUser(event.getUser()); - } - } - - public void registerEvents() { - this.selfListener = PacketEvents.getAPI().getEventManager().registerListener(this, PacketListenerPriority.NORMAL); - } - - public void unregisterEvents() { - PacketEvents.getAPI().getEventManager().unregisterListener(this.selfListener); - this.selfListener = null; - } - - public @NotNull Action createStaticAction(@NotNull IDialog dialog, @NotNull String id) { - Key key = this.createStaticKey(dialog, id); - return new StaticAction(new CustomClickEvent(new ResourceLocation(key), null)); - } - - public @NotNull ClickEvent createStaticClickEvent(@NotNull IDialog dialog, @NotNull String id) { - return ClickEvent.custom(this.createStaticKey(dialog, id), NONE_TAG_HOLDER); - } - - public @NotNull Action createSessionBasedAction(@NotNull IDialog dialog, @NotNull String id, boolean includeInputs) { - return this.createSessionBasedAction(dialog, id, null, includeInputs); - } - - public @NotNull Action createSessionBasedAction(@NotNull IDialog dialog, @NotNull String id, @Nullable NBTCompound data, boolean includeInputs) { - Key key = this.createSessionBasedKey(dialog, id); - CustomClickEvent clickEvent = new CustomClickEvent(new ResourceLocation(key), data); - return includeInputs ? new DynamicCustomAction(clickEvent.getId(), data) : new StaticAction(clickEvent); - } - - public @NotNull ClickEvent createSessionBasedClickEvent(@NotNull IDialog dialog, @NotNull String id) { - return ClickEvent.custom(this.createSessionBasedKey(dialog, id), null); - } - - public @NotNull ClickEvent createSessionBasedClickEvent(@NotNull IDialog dialog, @NotNull String id, @Nullable NBTCompound data) { - return ClickEvent.custom(this.createSessionBasedKey(dialog, id), data != null ? new NbtTagHolder(data) : NONE_TAG_HOLDER); - } - - private @NotNull Key createSessionBasedKey(@NotNull IDialog dialog, @NotNull String id) { - return KEY_GENERATOR.apply(dialog.getId() + "/" + id); - } - - private @NotNull Key createStaticKey(@NotNull IDialog dialog, @NotNull String id) { - Key key = this.createSessionBasedKey(dialog, id); - this.staticActions.add(key); - return key; - } - - public void startSessionIfNoneActive(@NotNull IDialog dialog, @NotNull IPlayer player, @NotNull Supplier initialDataSupplier) { - if (this.isSessionActive(player, dialog)) return; - this.startSession(dialog, player, initialDataSupplier.get()); - } - - public void startSession(@NotNull IDialog dialog, @NotNull IPlayer player, @NotNull Object initialData) { - this.setSessionData(player, initialData); - this.activeSessions.put(player.getUniqueId(), dialog.getId()); - } - - public void endSession(@NotNull IPlayer player) { - this.activeSessions.remove(player.getUniqueId()); - this.sessionData.remove(player.getUniqueId()); - } - - public boolean isSessionActive(@NotNull IPlayer player, @NotNull IDialog dialog) { - return dialog.getId().equals(this.activeSessions.get(player.getUniqueId())); - } - - public void setSessionData(@NotNull IPlayer player, @NotNull Object data) { - this.sessionData.put(player.getUniqueId(), data); - } - - public @NotNull T getSessionData(@NotNull IPlayer player, @NotNull Class dataType) throws ClassCastException { - return dataType.cast(this.sessionData.get(player.getUniqueId())); - } - - public boolean isFrozen(@NotNull IPlayer player) { - CompletableFuture future = this.frozenViewers.get(player.getUniqueId()); - return future != null && !future.isDone(); - } - - public void unfreeze(@NotNull IPlayer player) { - CompletableFuture future = this.frozenViewers.get(player.getUniqueId()); - if (future != null) future.complete(null); - } - - public void freeze(@NotNull IPlayer player) { - this.frozenViewers.computeIfAbsent(player.getUniqueId(), _ -> new CompletableFuture<>()); - } - - public void waitForUnfreeze(@NotNull IPlayer player) { - CompletableFuture future = this.frozenViewers.get(player.getUniqueId()); - if (future == null) return; - try { - future.join(); - } catch (CancellationException | CompletionException _) { - } - if (future.isDone()) this.frozenViewers.remove(player.getUniqueId()); - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/dialog/IDialog.java b/base/src/main/java/com/codingcat/changelogs/base/dialog/IDialog.java deleted file mode 100644 index bcb3ebe..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/dialog/IDialog.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.codingcat.changelogs.base.dialog; - -import com.codingcat.changelogs.base.ServerChangelogs; -import com.codingcat.changelogs.base.dialog.ui.ChangelogDialog; -import com.codingcat.changelogs.base.dialog.ui.editor.ChangelogEditorDialog; -import com.codingcat.changelogs.platformapi.player.IPlayer; -import com.github.retrooper.packetevents.protocol.dialog.Dialog; -import com.github.retrooper.packetevents.protocol.nbt.NBTCompound; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.*; -import java.util.function.Consumer; - -public interface IDialog { - @NotNull Dialog build(@NotNull IPlayer player, @NotNull DialogSessionManager sessionManager); - - default void showTo(@NotNull IPlayer player, @NotNull DialogSessionManager sessionManager, @NotNull Object session, @NotNull DialogPackets.PacketPhase packetPhase) { - sessionManager.startSession(this, player, session); - this.showTo(player, sessionManager, packetPhase); - } - - default void showTo(@NotNull IPlayer player, @NotNull DialogSessionManager sessionManager, @NotNull DialogPackets.PacketPhase packetPhase) { - Dialog dialog = this.build(player, sessionManager); - DialogPackets.showDialog(player, dialog, packetPhase); - } - - void onActionTriggered(@NotNull String action, @Nullable NBTCompound data, @NotNull IPlayer source, @NotNull DialogSessionManager sessionManager); - - default void attemptDestroy() throws DestroyRejectedException { - } - - @NotNull String getId(); - - @RequiredArgsConstructor - final class Holder { - private final @NotNull ServerChangelogs plugin; - private final Map dialogMap = new HashMap<>(); - - public void recreate() { - Consumer register = d -> this.dialogMap.put(d.getId(), d); - this.dialogMap.clear(); - register.accept(new ChangelogEditorDialog( - plugin.getChangelogStorage(), - plugin.pluginConfig().useNativeFallbackPermissions() - )); - register.accept(new ChangelogDialog( - plugin.getChangelogStorage(), this, - plugin.pluginConfig().getDateFormatter(), - plugin.pluginConfig().showChangelogHeader(), - plugin.pluginConfig().createChangelogHeaderStack(), - plugin.pluginConfig().useNativeFallbackPermissions() - )); - } - - public void ensureCanReload() throws DestroyRejectedException { - for (IDialog dialog : this.dialogMap.values()) dialog.attemptDestroy(); - } - - public @NotNull IDialog getFromId(@NotNull String id) throws NullPointerException { - return Objects.requireNonNull(this.dialogMap.get(id), "No dialog found matching ID \"" + id + "\""); - } - - public @NotNull T getFromType(@NotNull Class cls) throws IllegalArgumentException { - return this.dialogMap.values().stream() - .filter(d -> cls.isAssignableFrom(d.getClass())) - .findAny().map(cls::cast) - .orElseThrow(() -> new IllegalArgumentException("No matching dialog found")); - } - } - - @Getter - @RequiredArgsConstructor - class DestroyRejectedException extends Exception { - private final @NotNull String key; - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.java b/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.java deleted file mode 100644 index f1909f2..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.java +++ /dev/null @@ -1,176 +0,0 @@ -package com.codingcat.changelogs.base.dialog.ui; - -import com.codingcat.changelogs.base.compat.PacketEventsFix; -import com.codingcat.changelogs.base.data.ChangelogEntry; -import com.codingcat.changelogs.base.data.ChangelogStorage; -import com.codingcat.changelogs.base.dialog.DialogPackets; -import com.codingcat.changelogs.base.dialog.DialogSessionManager; -import com.codingcat.changelogs.base.dialog.IDialog; -import com.codingcat.changelogs.base.dialog.ui.editor.ChangelogEditorDialog; -import com.codingcat.changelogs.base.dialog.ui.editor.EditorSession; -import com.codingcat.changelogs.platformapi.player.IPlayer; -import com.github.retrooper.packetevents.protocol.dialog.CommonDialogData; -import com.github.retrooper.packetevents.protocol.dialog.Dialog; -import com.github.retrooper.packetevents.protocol.dialog.DialogAction; -import com.github.retrooper.packetevents.protocol.dialog.NoticeDialog; -import com.github.retrooper.packetevents.protocol.dialog.body.DialogBody; -import com.github.retrooper.packetevents.protocol.dialog.body.ItemDialogBody; -import com.github.retrooper.packetevents.protocol.dialog.body.PlainMessage; -import com.github.retrooper.packetevents.protocol.dialog.body.PlainMessageDialogBody; -import com.github.retrooper.packetevents.protocol.dialog.button.ActionButton; -import com.github.retrooper.packetevents.protocol.dialog.button.CommonButtonData; -import com.github.retrooper.packetevents.protocol.item.ItemStack; -import com.github.retrooper.packetevents.protocol.nbt.NBTCompound; -import com.github.retrooper.packetevents.protocol.nbt.NBTInt; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import net.kyori.adventure.text.Component; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.function.BiFunction; - -import static com.codingcat.changelogs.base.dialog.ui.editor.ChangelogEditorDialog.permissionCheck; -import static com.codingcat.changelogs.base.lang.TranslationSource.translatable; -import static com.codingcat.changelogs.base.lang.TranslationSource.translatableManual; -import static net.kyori.adventure.text.Component.text; - -@RequiredArgsConstructor -public class ChangelogDialog implements IDialog { - public static final int LINE_WIDTH = 440; - private final @Getter String id = "changelog_view"; - private final @NotNull ChangelogStorage storage; - private final @NotNull IDialog.Holder holder; - private final @NotNull DateTimeFormatter dateFormatter; - private final boolean addHeader; - private final @Nullable ItemStack headerItem; - private final boolean useFallbackPermissions; - - @Override - public @NotNull Dialog build(@NotNull IPlayer p, @NotNull DialogSessionManager sessionManager) { - boolean canManage = this.canManage(p, sessionManager); - if (canManage) sessionManager.startSessionIfNoneActive(this, p, Object::new); - List body = this.storage.listEntries() - .reversed().stream() - .map(e -> formatEntry(e, p, sessionManager, canManage)) - .map(c -> (DialogBody) new PlainMessageDialogBody(new PlainMessage(c, LINE_WIDTH))) - .toList(); - if (body.isEmpty()) - body = List.of(new PlainMessageDialogBody(new PlainMessage(translatableManual(p, "dialog.changelog.empty"), 350))); - if (this.addHeader) { - body = new ArrayList<>(body); - PlainMessage headerMessage = new PlainMessage(translatableManual(p, "dialog.changelog.header"), 260); - ItemDialogBody itemBody = null; - if (headerItem != null) { - itemBody = new ItemDialogBody(headerItem, headerMessage, false, false, 17, 17); - PacketEventsFix.fixItemBody(itemBody); - } - body.addFirst(headerItem != null ? itemBody : new PlainMessageDialogBody(headerMessage)); - } - CommonDialogData common = new CommonDialogData( - translatableManual(p, "dialog.changelog.title"), - null, true, false, - canManage ? DialogAction.NONE : DialogAction.CLOSE, body, List.of() - ); - ActionButton button = new ActionButton(new CommonButtonData( - translatableManual(p, "dialog.changelog.button.close"), - null, 60 - ), sessionManager.createStaticAction(this, "confirm_read")); - return new NoticeDialog(common, button); - } - - private @NotNull Component formatEntry(@NotNull ChangelogEntry entry, @NotNull IPlayer player, @NotNull DialogSessionManager sessionManager, boolean canManage) { - Component linesComponent = createLinesComponent(player, null, entry.lines()); - Component entryComponent = translatableManual(player, "dialog.changelog.entry" + (!entry.hasRead(player) ? "_unread" : ""), - text(dateFormatter.format(entry.recordedAt())), linesComponent, - Objects.requireNonNullElseGet(entry.author(), () -> translatableManual(player, "dialog.changelog.unspecified_author")) - ); - if (canManage) { - String prefix = "dialog.changelog.manage.changelog_action."; - NBTCompound payload = new NBTCompound(); - payload.setTag("uid", new NBTInt(entry.uid())); - Component edit = translatableManual(player, prefix + "format", translatable(prefix + "edit")) - .clickEvent(sessionManager.createSessionBasedClickEvent(this, "start_editing", payload)); - Component delete = translatableManual(player, prefix + "format", translatable(prefix + "delete")) - .clickEvent(sessionManager.createSessionBasedClickEvent(this, "request_delete", payload)); - entryComponent = entryComponent.appendSpace().append(edit).appendSpace().append(delete); - } - return entryComponent; - } - - public static @NotNull Component createLinesComponent(@NotNull IPlayer player, @Nullable BiFunction mapper, @NotNull List lines) { - if (mapper == null) mapper = (c, _) -> c; - Component component = Component.empty(); - for (int i = 0; i < lines.size(); i++) { - Component line = lines.get(i); - line = translatableManual(player, "dialog.changelog.entry_line", line); - line = mapper.apply(line, i); - component = component.append(line); - if (i < lines.size() - 1) component = component.appendNewline(); - } - return component; - } - - @Override - public void onActionTriggered(@NotNull String action, @Nullable NBTCompound data, @NotNull IPlayer source, @NotNull DialogSessionManager sessionManager) { - try { - boolean canManage = this.canManage(source, sessionManager); - switch (action) { - case "confirm_read" -> { - List uids = this.storage.listEntries() - .stream() - .filter(e -> !e.hasRead(source)) - .map(ChangelogEntry::uid) - .toList(); - uids.forEach(uid -> storage.markAsRead(uid, source.getUniqueId())); - if (canManage) { - sessionManager.endSession(source); - DialogPackets.clearDialog(source, DialogPackets.PacketPhase.PLAY); - } - if (!uids.isEmpty()) - source.asAudience().sendMessage(translatable("dialog.changelog.read", text(uids.size()))); - } - case "reopen" -> { - if (canManage) this.showTo(source, sessionManager, DialogPackets.PacketPhase.PLAY); - } - case "start_editing" -> { - if (data == null || !canManage) return; - int uid = data.getNumberTagValueOrThrow("uid").intValue(); - ChangelogEntry entry = this.storage.getByUID(uid); - if (entry == null) return; - sessionManager.endSession(source); - EditorSession.Edit session = new EditorSession.Edit(entry); - this.holder.getFromType(ChangelogEditorDialog.class) - .showTo(source, sessionManager, session, DialogPackets.PacketPhase.PLAY); - } - case "request_delete" -> { - if (data == null || !canManage) return; - int uid = data.getNumberTagValueOrThrow("uid").intValue(); - NBTCompound payload = new NBTCompound(); - payload.setTag("uid", new NBTInt(uid)); - String prefix = "dialog.changelog.manage.confirm_delete."; - DialogPackets.showSimpleConfirm(source, prefix + "title", prefix + "content", - sessionManager.createSessionBasedAction(this, "confirm_delete", payload, false), true, - sessionManager.createSessionBasedAction(this, "reopen", false), DialogPackets.PacketPhase.PLAY); - } - case "confirm_delete" -> { - if (data == null || !canManage) return; - int uid = data.getNumberTagValueOrThrow("uid").intValue(); - this.storage.removeEntry(uid); - this.showTo(source, sessionManager, DialogPackets.PacketPhase.PLAY); - } - } - } finally { - // Ensure the player is able to continue gameplay even if any exceptions occur - if (action.equals("confirm_read")) sessionManager.unfreeze(source); - } - } - - private boolean canManage(@NotNull IPlayer player, @NotNull DialogSessionManager sessionManager) { - return permissionCheck("manage", player, useFallbackPermissions) && !sessionManager.isFrozen(player); - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.java b/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.java deleted file mode 100644 index c60eb97..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.java +++ /dev/null @@ -1,232 +0,0 @@ -package com.codingcat.changelogs.base.dialog.ui.editor; - -import com.codingcat.changelogs.base.ServerChangelogs; -import com.codingcat.changelogs.base.compat.PacketEventsFix; -import com.codingcat.changelogs.base.data.ChangelogStorage; -import com.codingcat.changelogs.base.dialog.DialogPackets; -import com.codingcat.changelogs.base.dialog.DialogSessionManager; -import com.codingcat.changelogs.base.dialog.IDialog; -import com.codingcat.changelogs.base.dialog.ui.ChangelogDialog; -import com.codingcat.changelogs.platformapi.player.IPlayer; -import com.github.retrooper.packetevents.protocol.dialog.CommonDialogData; -import com.github.retrooper.packetevents.protocol.dialog.Dialog; -import com.github.retrooper.packetevents.protocol.dialog.DialogAction; -import com.github.retrooper.packetevents.protocol.dialog.MultiActionDialog; -import com.github.retrooper.packetevents.protocol.dialog.action.Action; -import com.github.retrooper.packetevents.protocol.dialog.body.DialogBody; -import com.github.retrooper.packetevents.protocol.dialog.body.ItemDialogBody; -import com.github.retrooper.packetevents.protocol.dialog.body.PlainMessage; -import com.github.retrooper.packetevents.protocol.dialog.body.PlainMessageDialogBody; -import com.github.retrooper.packetevents.protocol.dialog.button.ActionButton; -import com.github.retrooper.packetevents.protocol.dialog.button.CommonButtonData; -import com.github.retrooper.packetevents.protocol.dialog.input.Input; -import com.github.retrooper.packetevents.protocol.dialog.input.TextInputControl; -import com.github.retrooper.packetevents.protocol.item.ItemStack; -import com.github.retrooper.packetevents.protocol.item.type.ItemTypes; -import com.github.retrooper.packetevents.protocol.nbt.NBTByte; -import com.github.retrooper.packetevents.protocol.nbt.NBTCompound; -import com.github.retrooper.packetevents.protocol.nbt.NBTInt; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.TextDecoration; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.BiFunction; -import java.util.function.Function; - -import static com.codingcat.changelogs.base.dialog.ui.ChangelogDialog.createLinesComponent; -import static com.codingcat.changelogs.base.lang.TranslationSource.translatable; -import static com.codingcat.changelogs.base.lang.TranslationSource.translatableManual; -import static net.kyori.adventure.text.Component.text; - -@RequiredArgsConstructor -public class ChangelogEditorDialog implements IDialog { - private static final @NotNull Function TITLE_KEY = s -> "dialog.editor." + s.getId() + ".title"; - private final @NotNull Map savedSessions = new ConcurrentHashMap<>(); - private final @Getter String id = "changelog_editor"; - private final @NotNull ChangelogStorage storage; - private final boolean useFallbackPermissions; - - @Override - public @NotNull Dialog build(@NotNull IPlayer p, @NotNull DialogSessionManager sessionManager) { - sessionManager.startSessionIfNoneActive(this, p, () -> { - EditorSession session = this.savedSessions.remove(p.getUniqueId()); - if (session == null || !session.canBeSaved()) session = new EditorSession.Create(storage.nextUID()); - return session; - }); - EditorSession session = sessionManager.getSessionData(p, EditorSession.class); - String sessionTranslation = "dialog.editor." + session.getId(); - List previewLines = session.deserializeLines(); - BiFunction lineMapper = (line, idx) -> { - boolean isEditing = session.getEditingLineIndex() == idx; - if (isEditing) line = line.decoration(TextDecoration.BOLD, true); - String prefix = "dialog.editor.line_action."; - NBTCompound payload = new NBTCompound(); - payload.setTag("target_line", new NBTInt(idx)); - Component edit = null; - if (!isEditing) edit = translatableManual(p, prefix + "format", translatable(prefix + "edit")) - .decoration(TextDecoration.BOLD, false) - .clickEvent(sessionManager.createSessionBasedClickEvent(this, "start_edit_line", payload)); - Component remove = translatableManual(p, prefix + "format", translatable(prefix + "remove")) - .decoration(TextDecoration.BOLD, false) - .clickEvent(sessionManager.createSessionBasedClickEvent(this, "remove_line", payload)); - Component finalCmp = line.appendSpace(); - if (!isEditing) finalCmp = finalCmp.append(edit).appendSpace(); - return finalCmp.append(remove); - }; - ItemDialogBody itemBody = new ItemDialogBody(ItemStack.builder().type(ItemTypes.WRITABLE_BOOK).build(), - new PlainMessage(translatableManual(p, sessionTranslation + ".subtitle", text(session.getEntryUID() + 1)), 160), - false, false, 15, 15); - PacketEventsFix.fixItemBody(itemBody); - List body = List.of(itemBody, - new PlainMessageDialogBody(new PlainMessage(translatableManual(p, "dialog.editor.hint"), ChangelogDialog.LINE_WIDTH)), - new PlainMessageDialogBody(new PlainMessage(!previewLines.isEmpty() ? createLinesComponent(p, lineMapper, previewLines) : translatableManual(p, "dialog.editor.empty_preview"), ChangelogDialog.LINE_WIDTH)) - ); - if (session.isShowRestoredMessage()) { - body = new ArrayList<>(body); - body.add(1, new PlainMessageDialogBody(new PlainMessage(translatableManual(p, "dialog.editor.restored_session"), 400))); - } - List inputs = List.of( - new Input("line", new TextInputControl(350, translatableManual(p, "dialog.editor.input.contents"), - true, session.getCurrentLine(), 5000, null)), - new Input("author", new TextInputControl(200, translatableManual(p, "dialog.editor.input.author"), true, session.getAuthor(), 200, null)) - ); - CommonDialogData common = new CommonDialogData( - translatableManual(p, TITLE_KEY.apply(session)), - null, true, false, - DialogAction.NONE, body, inputs - ); - List buttons = new ArrayList<>(); - buttons.add(new ActionButton(new CommonButtonData(translatableManual(p, sessionTranslation + ".commit_button"), null, 160), sessionManager.createSessionBasedAction(this, "commit", true))); - String lineAction = session.getEditingLineIndex() != -1 ? "edit_line" : "add_line"; - buttons.add(new ActionButton(new CommonButtonData(translatableManual(p, "dialog.editor.button." + lineAction), null, 100), sessionManager.createSessionBasedAction(this, lineAction, true))); - ActionButton closeBtn = new ActionButton(new CommonButtonData(translatableManual(p, "dialog.editor.button.close"), null, 100), sessionManager.createSessionBasedAction(this, "try_close", true)); - return new MultiActionDialog(common, buttons, closeBtn, 3); - } - - private @NotNull Dialog buildConfirmCloseDialog(@NotNull IPlayer p, @NotNull DialogSessionManager sessionManager) { - EditorSession session = sessionManager.getSessionData(p, EditorSession.class); - String prefix = "dialog.editor.confirm_close."; - CommonDialogData common = DialogPackets.createSimpleDialog(p, TITLE_KEY.apply(session), prefix + "content", false, false); - Function closeActionFunc = save -> { - NBTCompound payload = new NBTCompound(); - payload.setTag("save_session", new NBTByte(save)); - return sessionManager.createSessionBasedAction(this, "close", payload, false); - }; - ActionButton saveSession = new ActionButton(new CommonButtonData(translatableManual(p, prefix + "button.save_session"), null, 140), closeActionFunc.apply(true)); - ActionButton discardSession = new ActionButton(new CommonButtonData(translatableManual(p, prefix + "button.discard_session"), null, 140), closeActionFunc.apply(false)); - ActionButton cancel = new ActionButton(new CommonButtonData(translatableManual(p, prefix + "button.cancel"), null, 100), sessionManager.createSessionBasedAction(this, "reopen", false)); - return new MultiActionDialog(common, List.of(saveSession, discardSession, cancel), null, 3); - } - - private void actuallyClose(@NotNull IPlayer source, @NotNull DialogSessionManager sessionManager) { - sessionManager.endSession(source); - DialogPackets.clearDialog(source, DialogPackets.PacketPhase.PLAY); - } - - @Override - public void onActionTriggered(@NotNull String action, @Nullable NBTCompound data, @NotNull IPlayer source, @NotNull DialogSessionManager sessionManager) { - EditorSession session = sessionManager.getSessionData(source, EditorSession.class); - session.setShowRestoredMessage(false); - if (data != null && data.contains("line")) { - String rawLine = data.getStringTagValueOrThrow("line"); - String rawAuthor = data.getStringTagValueOrThrow("author"); - session.setCurrentLine(rawLine); - session.setAuthor(rawAuthor); - } - switch (action) { - case "try_close" -> { - if (session.canBeSaved()) { - Dialog dialog = this.buildConfirmCloseDialog(source, sessionManager); - DialogPackets.showDialog(source, dialog, DialogPackets.PacketPhase.PLAY); - return; - } - this.actuallyClose(source, sessionManager); - } - case "close" -> { - if (data == null) return; - boolean saveSession = data.getBooleanOrThrow("save_session"); - if (saveSession) { - session.setShowRestoredMessage(true); - this.savedSessions.put(source.getUniqueId(), session); - } - this.actuallyClose(source, sessionManager); - } - case "reopen" -> this.showTo(source, sessionManager, DialogPackets.PacketPhase.PLAY); - case "add_line", "edit_line" -> { - if (action.equals("edit_line") && (session.getEditingLineIndex() == -1)) return; - boolean removed = false; - if (session.getCurrentLine().isBlank()) { - if (action.equals("add_line")) { - this.showRetry(source, "add_empty_line", session, sessionManager); - return; - } else { - session.getRawLines().remove(session.getEditingLineIndex()); - removed = true; - } - } - if (action.equals("edit_line")) { - if (!removed) session.getRawLines().set(session.getEditingLineIndex(), session.getCurrentLine()); - session.setEditingLineIndex(-1); - } else session.getRawLines().add(session.getCurrentLine()); - session.setCurrentLine(""); - this.showTo(source, sessionManager, DialogPackets.PacketPhase.PLAY); - } - case "start_edit_line", "remove_line" -> { - if (data == null) return; - int lineIdx = data.getNumberTagValueOrThrow("target_line").intValue(); - if (lineIdx < 0 || lineIdx >= session.getRawLines().size()) return; - if (action.equals("start_edit_line")) { - String line = session.getRawLines().get(lineIdx); - session.setCurrentLine(line); - session.setEditingLineIndex(lineIdx); - } else { - if (session.getEditingLineIndex() == lineIdx) session.setEditingLineIndex(-1); - session.getRawLines().remove(lineIdx); - session.setCurrentLine(""); - } - this.showTo(source, sessionManager, DialogPackets.PacketPhase.PLAY); - } - case "commit" -> { - if (!permissionCheck(session.getPermission(), source, useFallbackPermissions)) { - sessionManager.endSession(source); - DialogPackets.showSimpleNotice(source, TITLE_KEY.apply(session), "dialog.editor.error.no_permission"); - return; - } - if (data == null) return; - if (session.getRawLines().isEmpty()) { - this.showRetry(source, "no_lines", session, sessionManager); - return; - } - try { - session.commit(this.storage); - } catch (EditorSession.CommitException e) { - this.showRetry(source, session.getId() + "." + e.getTranslationKeyPart(), session, sessionManager); - return; - } - sessionManager.endSession(source); - DialogPackets.showSimpleNotice(source, TITLE_KEY.apply(session), "dialog.editor." + session.getId() + ".success"); - } - } - } - - @Override - public void attemptDestroy() throws DestroyRejectedException { - if (!this.savedSessions.isEmpty()) throw new DestroyRejectedException("saved_sessions"); - } - - private void showRetry(@NotNull IPlayer source, @NotNull String errorPart, @NotNull EditorSession session, @NotNull DialogSessionManager sessionManager) { - DialogPackets.showSimpleNotice(source, TITLE_KEY.apply(session), "dialog.editor.error." + errorPart, sessionManager.createSessionBasedAction(this, "reopen", false), DialogPackets.PacketPhase.PLAY); - } - - public static boolean permissionCheck(@NotNull String permission, @NotNull IPlayer source, boolean useFallbackPermissions) { - return useFallbackPermissions ? source.isNativeAdmin().toBooleanOrElse(false) : source.hasPermission(ServerChangelogs.NAMESPACE + "." + permission); - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.java b/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.java deleted file mode 100644 index 979118e..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.java +++ /dev/null @@ -1,134 +0,0 @@ -package com.codingcat.changelogs.base.dialog.ui.editor; - -import com.codingcat.changelogs.base.data.ChangelogEntry; -import com.codingcat.changelogs.base.data.ChangelogStorage; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.Setter; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.MiniMessage; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.Unmodifiable; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; - -@Getter -@Setter -@RequiredArgsConstructor -public abstract class EditorSession { - private final @NotNull List rawLines = new ArrayList<>(); - private final int entryUID; - private int editingLineIndex = -1; - private @NotNull String currentLine = ""; - private @NotNull String author = ""; - private boolean showRestoredMessage = false; - - abstract void commit(@NotNull ChangelogStorage storage) throws CommitException; - - public abstract @NotNull String getId(); - - public abstract @NotNull String getPermission(); - - public abstract boolean canBeSaved(); - - public @NotNull @Unmodifiable List deserializeLines() { - return getRawLines().stream() - .map(MiniMessage.miniMessage()::deserialize) - .toList(); - } - - public @Nullable Component deserializeAuthor() { - return !author.isBlank() ? MiniMessage.miniMessage().deserialize(getAuthor()) : null; - } - - public static class Create extends EditorSession { - public Create(int entryUID) { - super(entryUID); - } - - @Override - public void commit(@NotNull ChangelogStorage storage) throws CommitException { - ChangelogEntry newEntry = new ChangelogEntry( - getEntryUID(), - this.deserializeLines(), - Instant.now(), - this.deserializeAuthor(), - new HashSet<>() - ); - try { - storage.storeEntry(newEntry); - } catch (Throwable e) { - throw new CommitException("internal_error"); - } - } - - @Override - public @NotNull String getId() { - return "create"; - } - - @Override - public @NotNull String getPermission() { - return "command.create"; - } - - @Override - public boolean canBeSaved() { - return !getRawLines().isEmpty() || !getAuthor().isBlank(); - } - } - - @Getter - public static class Edit extends EditorSession { - public Edit(@NotNull ChangelogEntry entry) { - super(entry.uid()); - entry.lines().stream() - .map(MiniMessage.miniMessage()::serialize) - .forEach(getRawLines()::add); - if (entry.author() != null) this.setAuthor(MiniMessage.miniMessage().serialize(entry.author())); - } - - @Override - public void commit(@NotNull ChangelogStorage storage) throws CommitException { - ChangelogEntry currentEntry = storage.getByUID(getEntryUID()); - if (currentEntry == null) throw new CommitException("entry_deleted"); - ChangelogEntry newEntry = new ChangelogEntry( - currentEntry.uid(), - this.deserializeLines(), - currentEntry.recordedAt(), - this.deserializeAuthor(), - currentEntry.playersRead() - ); - try { - storage.updateEntry(newEntry); - } catch (Throwable e) { - throw new CommitException("internal_error"); - } - } - - @Override - public @NotNull String getId() { - return "edit"; - } - - @Override - public @NotNull String getPermission() { - return "manage"; - } - - @Override - public boolean canBeSaved() { - return false; - } - } - - @Getter - @RequiredArgsConstructor - public static class CommitException extends Exception { - private final @NotNull String translationKeyPart; - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/event/ChangelogJoinListener.java b/base/src/main/java/com/codingcat/changelogs/base/event/ChangelogJoinListener.java deleted file mode 100644 index 794cc07..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/event/ChangelogJoinListener.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.codingcat.changelogs.base.event; - -import com.codingcat.changelogs.base.ServerChangelogs; -import com.codingcat.changelogs.base.config.PluginConfig; -import com.codingcat.changelogs.base.data.ChangelogStorage; -import com.codingcat.changelogs.base.dialog.DialogSessionManager; -import com.codingcat.changelogs.base.dialog.IDialog; -import com.codingcat.changelogs.base.dialog.ui.ChangelogDialog; -import com.codingcat.changelogs.platformapi.event.IEventManager; -import com.codingcat.changelogs.platformapi.event.impl.PlayerEnterConfigurationPhaseEvent; -import com.codingcat.changelogs.platformapi.event.impl.PlayerJoinEvent; -import com.codingcat.changelogs.platformapi.player.IPlayer; -import lombok.RequiredArgsConstructor; -import net.kyori.adventure.key.Key; -import org.jetbrains.annotations.NotNull; - -import java.util.function.Supplier; - -import static com.codingcat.changelogs.base.ServerChangelogs.info; -import static net.kyori.adventure.text.Component.text; - -@RequiredArgsConstructor -public class ChangelogJoinListener { - private static final @NotNull Key KEY = ServerChangelogs.KEY_GENERATOR.apply("listener/changelog_display"); - private final @NotNull Supplier storageSupplier; - private final @NotNull IDialog.Holder dialogHolder; - private final @NotNull DialogSessionManager sessionManager; - private final @NotNull PluginConfig config; - - public void registerEvents(@NotNull IEventManager manager) { - switch (config.getDialogPacketPhase()) { - case PLAY -> - manager.registerIdentified(PlayerJoinEvent.class, KEY, e -> checkShowDialog(e.getPlayer(), false)); - case CONFIGURATION -> manager.registerIdentified(PlayerEnterConfigurationPhaseEvent.class, KEY, e -> { - if (checkShowDialog(e.getPlayer(), true)) { - info("console.join.frozen", text(e.getPlayer().getUniqueId().toString())); - sessionManager.waitForUnfreeze(e.getPlayer()); - } - }); - } - } - - public void unregisterEvents(@NotNull IEventManager manager) { - manager.unregisterIdentified(KEY); - } - - public boolean checkShowDialog(@NotNull IPlayer player, boolean freeze) { - if (player.isFirstJoin()) return false; - boolean unreadChangelogs = this.storageSupplier.get().listEntries().stream() - .anyMatch(e -> !e.hasRead(player)); - if (!unreadChangelogs) return false; - ChangelogDialog dialog = this.dialogHolder.getFromType(ChangelogDialog.class); - if (freeze) this.sessionManager.freeze(player); - dialog.showTo(player, this.sessionManager, config.getDialogPacketPhase()); - return true; - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/lang/TranslationSource.java b/base/src/main/java/com/codingcat/changelogs/base/lang/TranslationSource.java deleted file mode 100644 index cfabe13..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/lang/TranslationSource.java +++ /dev/null @@ -1,160 +0,0 @@ -package com.codingcat.changelogs.base.lang; - -import com.codingcat.changelogs.base.ServerChangelogs; -import com.codingcat.changelogs.platformapi.meta.ChangelogsMeta; -import com.codingcat.changelogs.platformapi.meta.PlatformMeta; -import com.codingcat.changelogs.platformapi.player.IPlayer; -import lombok.RequiredArgsConstructor; -import net.kyori.adventure.key.Key; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.ComponentLike; -import net.kyori.adventure.text.TranslatableComponent; -import net.kyori.adventure.text.logger.slf4j.ComponentLogger; -import net.kyori.adventure.text.minimessage.MiniMessage; -import net.kyori.adventure.text.minimessage.tag.Tag; -import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; -import net.kyori.adventure.text.minimessage.translation.MiniMessageTranslationStore; -import net.kyori.adventure.translation.GlobalTranslator; -import net.kyori.adventure.translation.TranslationStore; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.Unmodifiable; -import org.yaml.snakeyaml.Yaml; -import org.yaml.snakeyaml.error.YAMLException; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.nio.file.Path; -import java.util.*; - -import static net.kyori.adventure.text.Component.text; - -@RequiredArgsConstructor -public final class TranslationSource { - private final @NotNull Path sourceDirectory; - private final @NotNull ChangelogsMeta changelogsMeta; - private final @NotNull PlatformMeta platformMeta; - private final @NotNull ComponentLogger logger; - private final Key storeKey = ServerChangelogs.KEY_GENERATOR.apply("translations"); - private final MiniMessage miniMessage = MiniMessage.builder() - .tags(createGlobalTagResolver()) - .build(); - private @Nullable TranslationStore translationStore; - - public void reload() { - logger.info("Reloading translation store..."); - File[] files = sourceDirectory.toFile().listFiles(p -> p.getName().endsWith(".yml") || p.getName().endsWith(".yaml")); - if (files == null) { - logger.warn("Failed to find translation directory, no translations will be loaded! (Restart the server to re-generate the defaults!)"); - return; - } - if (files.length == 0) { - logger.warn("No translation files present, unable to load language! (Delete the \"lang\" directory and restart the server to re-generate the defaults!)"); - return; - } - Map> rawTranslations = new HashMap<>(); - for (File file : files) { - String withoutExtension = file.getName().substring(0, file.getName().lastIndexOf('.')); - Map data = this.loadTranslationFile(file.toPath()); - if (data == null) continue; - rawTranslations.put(withoutExtension, data); - } - GlobalTranslator globalTranslator = GlobalTranslator.translator(); - if (this.translationStore != null) globalTranslator.removeSource(this.translationStore); - this.translationStore = this.createTranslationStore(rawTranslations); - globalTranslator.addSource(this.translationStore); - } - - private @NotNull TranslationStore createTranslationStore(@NotNull Map> rawLanguageData) { - MiniMessageTranslationStore store = MiniMessageTranslationStore.create(this.storeKey, this.miniMessage); - store.defaultLocale(Locale.US); - for (Map.Entry> entry : rawLanguageData.entrySet()) { - Locale locale; - try { - String key = entry.getKey().replace("-", "_"); - if (key.endsWith("_")) key = key.replace("_", ""); - locale = key.contains("_") ? Locale.of( - key.substring(0, key.indexOf('_')).toLowerCase(Locale.ROOT), - key.substring(key.indexOf("_") + 1).toUpperCase(Locale.ROOT) - ) : Locale.of(key); - } catch (Exception e) { - logger.warn("Skipping unknown language entry \"{}\"", entry.getKey()); - continue; - } - store.registerAll(locale, entry.getValue()); - } - return store; - } - - private @Unmodifiable @Nullable Map loadTranslationFile(@NotNull Path path) { - File file = path.toFile(); - String fileName = path.getFileName().toString(); - if (!file.exists()) { - logger.warn("Failed to find language file {}, skipping language!", fileName); - return null; - } - Yaml langFile = new Yaml(); - try { - Map data = langFile.load(new FileInputStream(file)); - Map translations = new HashMap<>(); - this.addRawTranslationsRecursive(translations, ServerChangelogs.NAMESPACE + ".", data); - return translations; - } catch (IOException e) { - logger.warn("Failed to load language file {} due to I/O errors:", fileName, e); - } catch (YAMLException e) { - logger.warn("Invalid syntax in language file {}:", fileName, e); - } - return null; - } - - private void addRawTranslationsRecursive(@NotNull Map rawTranslations, @NotNull String prefix, @NotNull Map data) { - data.forEach((k, v) -> { - if (v instanceof String str) rawTranslations.put(prefix + k, str); - if (v instanceof Map map) //noinspection unchecked - addRawTranslationsRecursive(rawTranslations, prefix + k + ".", (Map) map); - }); - } - - private @NotNull TagResolver createGlobalTagResolver() { - return TagResolver.builder() - .resolver(TagResolver.standard()) - .tag("prefix", Tag.inserting(translatable("prefix"))) - .tag("translate", (args, _) -> Tag.selfClosingInserting(translatable(args.popOr("Missing translate argument").value()))) - .tag("plugin", (args, context) -> { - String property = args.popOr("Missing argument for tag \"plugin\"").lowerValue(); - String value = switch (property) { - case "name" -> changelogsMeta.getName(); - case "description" -> changelogsMeta.getDescription(); - case "version" -> changelogsMeta.getVersion(); - case "authors" -> Optional.ofNullable(changelogsMeta.getAuthors()) - .map(a -> String.join(", ", a)) - .orElse(null); - default -> throw context.newException("Invalid argument \"" + property + "\"", args); - }; - return Tag.selfClosingInserting(value != null ? text(value) : translatable("meta.unknown")); - }) - .tag("platform", (args, context) -> { - String property = args.popOr("Missing argument for tag \"platform\"").lowerValue(); - Component value = switch (property) { - case "name" -> platformMeta.getName(); - case "version" -> text(platformMeta.getVersion()); - default -> throw context.newException("Invalid argument \"" + property + "\"", args); - }; - return Tag.selfClosingInserting(value); - }).build(); - } - - public static @NotNull TranslatableComponent translatable(@NotNull String key, @NotNull ComponentLike... args) { - return Component.translatable(ServerChangelogs.NAMESPACE + "." + key, args); - } - - /** - * Used in dialogs specifically since paper doesn't provide {@link GlobalTranslator} - * support for those yet (see this issue) - */ - public static @NotNull Component translatableManual(@NotNull IPlayer player, @NotNull String key, @NotNull ComponentLike... args) { - Component translation = GlobalTranslator.render(translatable(key, args), player.getClientLocale()); - return Objects.requireNonNullElseGet(translation, () -> text(ServerChangelogs.NAMESPACE + "." + key)); - } -} diff --git a/base/src/main/java/com/codingcat/changelogs/base/util/ResourceUtil.java b/base/src/main/java/com/codingcat/changelogs/base/util/ResourceUtil.java deleted file mode 100644 index d2ba79c..0000000 --- a/base/src/main/java/com/codingcat/changelogs/base/util/ResourceUtil.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.codingcat.changelogs.base.util; - -import org.jetbrains.annotations.NotNull; - -import java.io.IOException; -import java.io.InputStream; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.charset.StandardCharsets; -import java.nio.file.FileSystem; -import java.nio.file.FileSystems; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.*; -import java.util.stream.Stream; - -public final class ResourceUtil { - private static final ClassLoader classLoader = ResourceUtil.class.getClassLoader(); - - public static @NotNull Map readResourcesAsString(@NotNull String dirPath) { - Collection resources = listResources(dirPath); - Map data = new HashMap<>(); - for (String resource : resources) { - String string = readResourceAsString(dirPath + "/" + resource); - data.put(resource, string); - } - return data; - } - - public static @NotNull String readResourceAsString(@NotNull String path) { - try (InputStream stream = classLoader.getResourceAsStream(path)) { - if (stream == null) throw new RuntimeException("Resource not found: " + path); - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); - } catch (IOException e) { - throw new RuntimeException("Failed to read jar resource at " + path, e); - } - } - - public static @NotNull Collection listResources(@NotNull String dirPath) { - try { - URI uri = Objects.requireNonNull(classLoader.getResource(dirPath), "Resource not found").toURI(); - FileSystem fileSystem = FileSystems.newFileSystem(uri, Collections.emptyMap()); - Path path = fileSystem.getPath(dirPath); - Collection resources; - try (Stream stream = Files.walk(path, 1)) { - resources = stream.map(p -> p.getFileName().toString()) - .filter(n -> !n.equals(path.getFileName().toString())) - .toList(); - } - fileSystem.close(); - return resources; - } catch (IOException | URISyntaxException e) { - throw new RuntimeException("Failed to list jar resources at " + dirPath, e); - } - } -} diff --git a/build.gradle.kts b/build.gradle.kts index fbfc06b..a675428 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,84 @@ +plugins { + id("org.jetbrains.kotlin.jvm") apply false + id("com.gradleup.shadow") apply false +} + +group = project.findProperty("group")?.toString() ?: missingProperty("group") +version = project.findProperty("version")?.toString() ?: missingProperty("version") +val javaVersion: Int = 25 + +val cleanFinalArtifacts = tasks.register("cleanFinalArtifacts") { + description = "Cleans the final directory with a backup of the previous state" + val finalFile = layout.projectDirectory.dir("final").asFile + val backupFile = layout.projectDirectory.dir("final_bak").asFile + + doFirst { + if (finalFile.listFiles()?.isEmpty() != false) return@doFirst + + backupFile.deleteRecursively() + finalFile.copyRecursively(target = backupFile, overwrite = true) + } + delete(finalFile) +} + +@Suppress("AvoidApplyPluginMethod") subprojects { + version = rootProject.version + apply(plugin = "org.jetbrains.kotlin.jvm") + apply(plugin = "java") + + java { + toolchain.languageVersion = JavaLanguageVersion.of(javaVersion) + } + + kotlin { + jvmToolchain(javaVersion) + } + + shadowJar { + mergeServiceFiles() + archiveFileName = "${rootProject.name}-${project.name}-${project.version}.jar" + destinationDirectory.set(rootProject.layout.projectDirectory.dir("final")) + mustRunAfter(cleanFinalArtifacts) + dependencies { + exclude(dependency("org.slf4j:slf4j-api")) + } + relocate("kotlinx.collections.immutable", "com.codingcat.changelogs.libs.immutable") + relocate("ca.solostudios.slf4k", "com.codingcat.changelogs.libs.slf4k") + } + repositories { mavenCentral() maven("https://repo.codemc.io/repository/maven-releases/") maven("https://libraries.minecraft.net") } + + tasks.named("build") { + dependsOn(cleanFinalArtifacts) + pluginManager.withPlugin("com.gradleup.shadow") { + dependsOn(tasks.named("shadowJar")) + } + } +} + +allprojects { + tasks.withType().configureEach { + val args = options.forkOptions.jvmArgs ?: mutableListOf() + if ("--enable-native-access=ALL-UNNAMED" !in args) { + args.add("--enable-native-access=ALL-UNNAMED") + } + options.forkOptions.jvmArgs = args + } +} + +@Suppress("unused") +tasks { + val clean = register("clean") { + description = "Deletes all build directories" + delete(rootProject.layout.buildDirectory) + + subprojects.forEach { subproject -> + delete(subproject.layout.buildDirectory) + } + } } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..052cf3a --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() + gradlePluginPortal() +} + +dependencies { + implementation(libs.kotlin.gradle.plugin) + implementation(libs.shadow.gradle.plugin) +} diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 0000000..62991c1 --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,9 @@ +rootProject.name = "buildSrc" + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/buildSrc/src/main/kotlin/BuildExtensions.kt b/buildSrc/src/main/kotlin/BuildExtensions.kt new file mode 100644 index 0000000..7669e49 --- /dev/null +++ b/buildSrc/src/main/kotlin/BuildExtensions.kt @@ -0,0 +1,28 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import org.gradle.api.Action +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.tasks.TaskContainer +import org.gradle.api.tasks.TaskProvider +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension + +fun Project.java(configure: Action): Unit = + extensions.configure("java", configure) + +fun Project.kotlin(configure: Action): Unit = + extensions.configure("kotlin", configure) + +fun Project.shadowJar(configure: Action) { + pluginManager.withPlugin("com.gradleup.shadow") { + tasks.named("shadowJar", ShadowJar::class.java, configure) + } +} + +fun TaskContainer.shadowJar(configure: Action): TaskProvider = + named("shadowJar", ShadowJar::class.java, configure) + +fun Project.missingProperty(name: String): Nothing = + throw IllegalStateException("Property '${name}' is missing. Please define it in gradle.properties.") + +fun missingProperty(name: String): Nothing = + throw IllegalStateException("Property '${name}' is missing. Please define it in gradle.properties.") diff --git a/gradle.properties b/gradle.properties index bdf2942..18013b8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,9 +1,15 @@ -group=com.codingcat -version=1.2.1 -description=Keep players up-to-date on server changes using intuitive, dialog-based UIs! -url=https://modrinth.com/plugin/server-changelogs -minecraft_version=26.1.2 -minecraft_version_compat=26.1 -org.gradle.configuration-cache=true -org.gradle.parallel=true -org.gradle.caching=true +group = com.codingcat +version = 1.2.1 +description = Keep players up to date on server changes using intuitive, dialog-based UIs! +url = https://modrinth.com/plugin/server-changelogs +minecraft_version = 26.1.2 +minecraft_version_compat = 26.1 +org.gradle.caching = true +org.gradle.configuration-cache = true +org.gradle.configureondemand = true +org.gradle.parallel = true +org.gradle.daemon = true +org.gradle.vfs.watch = false +org.gradle.jvmargs = -Xmx2g -XX:+UseParallelGC --enable-native-access=ALL-UNNAMED +kotlin.incremental = true +kotlin.caching.enabled = true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index be0baf3..25c37e6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] +kotlin = "2.4.20" +kotlinx-immutable = "0.5.2" adventure = "5.2.0" -lombok = "9.7.0" mojang-brigadier = "1.3.10" packetevents = "2.14.0" paper-api = "26.1.2.build.+" @@ -8,15 +9,21 @@ resource-factory = "1.3.1" run-task = "3.0.2" shadow = "9.6.1" slf4j = "2.0.18" +slf4k = "0.5.4" snakeyaml = "2.7" velocity-api = "3.5.0-SNAPSHOT" [libraries] +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +shadow-gradle-plugin = { module = "com.gradleup.shadow:shadow-gradle-plugin", version.ref = "shadow" } +kotlinx-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-immutable" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } adventure-api = { module = "net.kyori:adventure-api", version.ref = "adventure" } adventure-slf4j = { module = "net.kyori:adventure-text-logger-slf4j", version.ref = "adventure" } adventure-minimessage = { module = "net.kyori:adventure-text-minimessage", version.ref = "adventure" } adventure-gson = { module = "net.kyori:adventure-text-serializer-gson", version.ref = "adventure" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } +slf4k = { module = "ca.solo-studios:slf4k", version.ref = "slf4k" } mojang-brigadier = { module = "com.mojang:brigadier", version.ref = "mojang-brigadier" } packetevents-api = { module = "com.github.retrooper:packetevents-api", version.ref = "packetevents" } snakeyaml = { module = "org.yaml:snakeyaml", version.ref = "snakeyaml" } @@ -25,9 +32,15 @@ packetevents-velocity = { module = "com.github.retrooper:packetevents-velocity", paper-api = { module = "io.papermc.paper:paper-api", version.ref = "paper-api" } velocity-api = { module = "com.velocitypowered:velocity-api", version.ref = "velocity-api" } +[bundles] +adventure = [ + "adventure-api", + "adventure-slf4j", + "adventure-minimessage", + "adventure-gson", +] + [plugins] -lombok = { id = "io.freefair.lombok", version.ref = "lombok" } -shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } run-paper = { id = "xyz.jpenilla.run-paper", version.ref = "run-task" } run-velocity = { id = "xyz.jpenilla.run-velocity", version.ref = "run-task" } resource-factory-paper-convention = { id = "xyz.jpenilla.resource-factory-paper-convention", version.ref = "resource-factory" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c42672d..12e4ffc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists +distributionBase = GRADLE_USER_HOME +distributionPath = wrapper/dists +distributionUrl = https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip +networkTimeout = 10000 +retries = 0 +retryBackOffMs = 500 +validateDistributionUrl = true +zipStoreBase = GRADLE_USER_HOME +zipStorePath = wrapper/dists diff --git a/paper/src/main/java/com/codingcat/changelogs/paper/PaperChangelogsPlatform.java b/paper/src/main/java/com/codingcat/changelogs/paper/PaperChangelogsPlatform.java deleted file mode 100644 index c15fe47..0000000 --- a/paper/src/main/java/com/codingcat/changelogs/paper/PaperChangelogsPlatform.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.codingcat.changelogs.paper; - -import com.codingcat.changelogs.base.Entrypoints; -import com.codingcat.changelogs.paper.command.PaperCommandManager; -import com.codingcat.changelogs.paper.event.PaperEventManager; -import com.codingcat.changelogs.paper.item.PaperNativeItemManager; -import com.codingcat.changelogs.paper.meta.PaperPlatformMeta; -import com.codingcat.changelogs.paper.meta.PaperPluginChangelogsMeta; -import com.codingcat.changelogs.paper.player.PaperPlayerManager; -import com.codingcat.changelogs.platformapi.ChangelogsPlatform; -import com.codingcat.changelogs.platformapi.Entrypoint; -import lombok.Getter; -import net.kyori.adventure.text.logger.slf4j.ComponentLogger; -import org.bukkit.plugin.java.JavaPlugin; -import org.jetbrains.annotations.NotNull; - -import java.nio.file.Path; - -public class PaperChangelogsPlatform extends JavaPlugin implements ChangelogsPlatform { - private final @NotNull Entrypoint entrypoint = Entrypoints.create(this); - private final @Getter PaperPlatformMeta platformMeta = new PaperPlatformMeta(getServer()); - private final @Getter PaperPluginChangelogsMeta changelogsMeta = new PaperPluginChangelogsMeta(getPluginMeta()); - private final @Getter PaperNativeItemManager nativeItemManager = new PaperNativeItemManager(); - private final @Getter PaperPlayerManager playerManager = new PaperPlayerManager(getServer()); - private final @Getter PaperEventManager eventManager = new PaperEventManager(this, getServer().getPluginManager()); - private final @Getter PaperCommandManager commandManager = new PaperCommandManager(); - private boolean earlyInitSucceeded = false; - - @Override - public void onEnable() { - try { - Class.forName("com.github.retrooper.packetevents.PacketEvents"); - } catch (ClassNotFoundException | NoClassDefFoundError e) { - throw new RuntimeException("Unable to find PacketEvents! Please ensure you have the latest version of that plugin installed!", e); - } - this.commandManager.supplyPlayerManager(getPlayerManager()); - this.eventManager.initMappings(getPlayerManager()); - this.earlyInitSucceeded = true; - try { - this.entrypoint.onStart(); - } catch (Throwable e) { - throw new RuntimeException("Failed to start platform entrypoint", e); - } - this.commandManager.registerEvents(getLifecycleManager()); - } - - @Override - public void onDisable() { - if (!this.earlyInitSucceeded) return; - try { - this.entrypoint.onShutdown(); - } catch (Throwable e) { - throw new RuntimeException("Failed to shut down platform entrypoint", e); - } finally { - this.eventManager.unregisterAll(); - } - } - - @Override - public @NotNull Path getDataPath() { - return super.getDataPath(); - } - - @Override - public @NotNull ComponentLogger getComponentLogger() { - return super.getComponentLogger(); - } -} diff --git a/paper/src/main/java/com/codingcat/changelogs/paper/command/PaperCommandManager.java b/paper/src/main/java/com/codingcat/changelogs/paper/command/PaperCommandManager.java deleted file mode 100644 index 59ece0d..0000000 --- a/paper/src/main/java/com/codingcat/changelogs/paper/command/PaperCommandManager.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.codingcat.changelogs.paper.command; - -import com.codingcat.changelogs.paper.player.PaperPlayerManager; -import com.codingcat.changelogs.platformapi.command.ICommandManager; -import com.codingcat.changelogs.platformapi.command.ICommandSource; -import com.mojang.brigadier.tree.LiteralCommandNode; -import io.papermc.paper.command.brigadier.CommandSourceStack; -import io.papermc.paper.command.brigadier.Commands; -import io.papermc.paper.plugin.lifecycle.event.LifecycleEventManager; -import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents; -import org.bukkit.plugin.Plugin; -import org.jetbrains.annotations.NotNull; - -import java.util.HashSet; -import java.util.Set; -import java.util.function.Consumer; - -public class PaperCommandManager implements ICommandManager { - private final Set> registrarActions = new HashSet<>(); - private PaperPlayerManager playerManager; - - public void supplyPlayerManager(@NotNull PaperPlayerManager playerManager) { - this.playerManager = playerManager; - } - - @Override - public void register(@NotNull LiteralCommandNode node, @NotNull Set aliases) { - //noinspection unchecked - LiteralCommandNode node1 = (LiteralCommandNode) node; - this.registrarActions.add(c -> c.register(node1, aliases)); - } - - @Override - public @NotNull ICommandSource adaptPlatformSource(@NotNull Object platformCommandSource) throws IllegalArgumentException { - if (!(platformCommandSource instanceof CommandSourceStack sourceStack)) - throw new IllegalArgumentException("Expected native paper CommandSourceStack but got " + platformCommandSource); - return new WrappedCommandSourceStack(sourceStack, this.playerManager); - } - - public void registerEvents(@NotNull LifecycleEventManager lifecycleEventManager) { - lifecycleEventManager.registerEventHandler(LifecycleEvents.COMMANDS, commands -> - this.registrarActions.forEach(a -> a.accept(commands.registrar()))); - } -} diff --git a/paper/src/main/java/com/codingcat/changelogs/paper/command/WrappedCommandSourceStack.java b/paper/src/main/java/com/codingcat/changelogs/paper/command/WrappedCommandSourceStack.java deleted file mode 100644 index 751e18d..0000000 --- a/paper/src/main/java/com/codingcat/changelogs/paper/command/WrappedCommandSourceStack.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.codingcat.changelogs.paper.command; - -import com.codingcat.changelogs.paper.player.PaperPlayerManager; -import com.codingcat.changelogs.platformapi.command.ICommandSource; -import com.codingcat.changelogs.platformapi.player.IPlayer; -import io.papermc.paper.command.brigadier.CommandSourceStack; -import lombok.RequiredArgsConstructor; -import net.kyori.adventure.audience.Audience; -import net.kyori.adventure.permission.PermissionChecker; -import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -@RequiredArgsConstructor -public class WrappedCommandSourceStack implements ICommandSource { - private final @NotNull CommandSourceStack sourceStack; - private final @NotNull PaperPlayerManager playerManager; - - @Override - public @Nullable IPlayer getExecutingPlayer() { - return (this.sourceStack.getSender() instanceof Player p) ? playerManager.fromNative(p) : null; - } - - @Override - public @NotNull Audience asAudience() { - return this.sourceStack.getSender(); - } - - @Override - public @NotNull PermissionChecker asPermissionChecker() { - return this.sourceStack.getSender()::permissionValue; - } - - @Override - public @NotNull Object unwrapNative() { - return this.sourceStack; - } -} diff --git a/paper/src/main/java/com/codingcat/changelogs/paper/event/PaperEventManager.java b/paper/src/main/java/com/codingcat/changelogs/paper/event/PaperEventManager.java deleted file mode 100644 index 4f8a0f3..0000000 --- a/paper/src/main/java/com/codingcat/changelogs/paper/event/PaperEventManager.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.codingcat.changelogs.paper.event; - -import com.codingcat.changelogs.paper.player.PaperPlayerManager; -import com.codingcat.changelogs.platformapi.event.IEventManager; -import com.codingcat.changelogs.platformapi.event.impl.IEvent; -import com.codingcat.changelogs.platformapi.event.util.PlatformEventMappings; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.bukkit.event.*; -import org.bukkit.plugin.EventExecutor; -import org.bukkit.plugin.Plugin; -import org.bukkit.plugin.PluginManager; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.HashSet; -import java.util.Objects; -import java.util.Set; -import java.util.function.Consumer; -import java.util.function.Predicate; - -@RequiredArgsConstructor -public class PaperEventManager implements IEventManager { - private final @NotNull Plugin plugin; - private final @NotNull PluginManager pluginManager; - private final @NotNull Set> listenerSet = new HashSet<>(); - private @Nullable PaperEventMappings eventMappings; - - public void initMappings(@NotNull PaperPlayerManager playerManager) { - this.eventMappings = new PaperEventMappings(playerManager); - } - - @Override - public void registerListener(@NotNull Class eventCls, @NotNull Consumer listener) { - PlatformEventMappings.Mapping mapping = Objects.requireNonNull(eventMappings, "Event mappings not initialized").findForPlatformEvent(eventCls); - PlatformMappingListener mappingListener = new PlatformMappingListener<>(listener, mapping); - this.pluginManager.registerEvent(mapping.nativeEventCls(), mappingListener, EventPriority.NORMAL, mappingListener, plugin); - this.listenerSet.add(mappingListener); - } - - @Override - public void unregisterListener(@NotNull Consumer listener) { - this.unregisterIf(l -> l.equals(listener)); - } - - @Override - public void unregisterIf(@NotNull Predicate> listenerPredicate) { - Predicate> predicate = l -> listenerPredicate.test(l.listener); - this.listenerSet.stream() - .filter(predicate) - .forEach(HandlerList::unregisterAll); - this.listenerSet.removeIf(predicate); - } - - @Override - public void unregisterAll() { - this.listenerSet.forEach(HandlerList::unregisterAll); - this.listenerSet.clear(); - } - - @RequiredArgsConstructor - private static final class PlatformMappingListener implements Listener, EventExecutor { - private final @Getter Consumer listener; - private final @NotNull PlatformEventMappings.Mapping mapping; - - @Override - public void execute(@NotNull Listener listener, @NotNull Event event) throws EventException { - try { - //noinspection unchecked - T platformEvent = ((PlatformEventMappings.Mapping) mapping).convertToPlatform(event); - this.listener.accept(platformEvent); - } catch (Throwable e) { - throw new EventException(e, "Failed to handle event " + event + " (platform " + mapping.platformEventCls() + ")"); - } - } - } -} diff --git a/paper/src/main/java/com/codingcat/changelogs/paper/event/PaperEventMappings.java b/paper/src/main/java/com/codingcat/changelogs/paper/event/PaperEventMappings.java deleted file mode 100644 index ce3e694..0000000 --- a/paper/src/main/java/com/codingcat/changelogs/paper/event/PaperEventMappings.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.codingcat.changelogs.paper.event; - -import com.codingcat.changelogs.paper.player.PaperConfigurationPhaseConnectionWrapper; -import com.codingcat.changelogs.paper.player.PaperPlayerManager; -import com.codingcat.changelogs.platformapi.event.impl.PlayerEnterConfigurationPhaseEvent; -import com.codingcat.changelogs.platformapi.event.impl.PlayerJoinEvent; -import com.codingcat.changelogs.platformapi.event.util.PlatformEventMappings; -import io.papermc.paper.event.connection.configuration.AsyncPlayerConnectionConfigureEvent; -import org.bukkit.event.Event; -import org.jetbrains.annotations.NotNull; - -@SuppressWarnings("UnstableApiUsage") -public final class PaperEventMappings extends PlatformEventMappings { - PaperEventMappings(@NotNull PaperPlayerManager pm) { - this.register( - PlayerJoinEvent.class, - org.bukkit.event.player.PlayerJoinEvent.class, - ev -> new PlayerJoinEvent(pm.fromNative(ev.getPlayer()), true) - ); - this.register( - PlayerEnterConfigurationPhaseEvent.class, - AsyncPlayerConnectionConfigureEvent.class, - ev -> new PlayerEnterConfigurationPhaseEvent(new PaperConfigurationPhaseConnectionWrapper(ev.getConnection()), true) - ); - } -} diff --git a/paper/src/main/java/com/codingcat/changelogs/paper/item/PaperNativeItemManager.java b/paper/src/main/java/com/codingcat/changelogs/paper/item/PaperNativeItemManager.java deleted file mode 100644 index 84180d6..0000000 --- a/paper/src/main/java/com/codingcat/changelogs/paper/item/PaperNativeItemManager.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.codingcat.changelogs.paper.item; - -import com.codingcat.changelogs.platformapi.item.NativeItemManager; -import io.github.retrooper.packetevents.util.SpigotConversionUtil; -import io.papermc.paper.registry.RegistryAccess; -import io.papermc.paper.registry.RegistryKey; -import net.kyori.adventure.key.Key; -import org.bukkit.Bukkit; -import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.ItemType; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -public class PaperNativeItemManager implements NativeItemManager { - @Override - public @NotNull Object adaptToPEStack(@NotNull Object nativeStack) throws IllegalArgumentException { - if (!(nativeStack instanceof ItemStack itemStack)) - throw new IllegalArgumentException("Expected native bukkit ItemStack but got " + nativeStack); - return SpigotConversionUtil.fromBukkitItemStack(itemStack); - } - - @Override - public @Nullable Object createNativeStack(@NotNull Key key, int amount) { - ItemType type = RegistryAccess.registryAccess().getRegistry(RegistryKey.ITEM).get(key); - return type != null ? type.createItemStack(amount) : null; - } - - @Override - @SuppressWarnings("deprecation") - public @NotNull Object applyComponentStr(@NotNull Object nativeStack, @NotNull String componentStr) throws IllegalArgumentException { - if (!(nativeStack instanceof ItemStack itemStack)) - throw new IllegalArgumentException("Expected native bukkit ItemStack but got " + nativeStack); - return Bukkit.getUnsafe().modifyItemStack(itemStack, componentStr); - } -} diff --git a/paper/src/main/java/com/codingcat/changelogs/paper/meta/PaperPlatformMeta.java b/paper/src/main/java/com/codingcat/changelogs/paper/meta/PaperPlatformMeta.java deleted file mode 100644 index b8620bf..0000000 --- a/paper/src/main/java/com/codingcat/changelogs/paper/meta/PaperPlatformMeta.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.codingcat.changelogs.paper.meta; - -import com.codingcat.changelogs.platformapi.meta.PlatformMeta; -import lombok.RequiredArgsConstructor; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.format.TextColor; -import org.bukkit.Server; -import org.jetbrains.annotations.NotNull; - -import java.util.Locale; - -import static net.kyori.adventure.text.Component.text; - -@RequiredArgsConstructor -public final class PaperPlatformMeta implements PlatformMeta { - private final @NotNull Server server; - - @Override - public @NotNull Component getName() { - TextColor color = NamedTextColor.AQUA; - String lowerName = server.getName().toLowerCase(Locale.ENGLISH); - if (lowerName.contains("purpur")) - color = NamedTextColor.LIGHT_PURPLE; - else if (lowerName.contains("folia")) - color = NamedTextColor.GREEN; - return text(this.server.getName(), color); - } - - @Override - public @NotNull String getVersion() { - return this.server.getVersion(); - } -} diff --git a/paper/src/main/java/com/codingcat/changelogs/paper/meta/PaperPluginChangelogsMeta.java b/paper/src/main/java/com/codingcat/changelogs/paper/meta/PaperPluginChangelogsMeta.java deleted file mode 100644 index ad4bf60..0000000 --- a/paper/src/main/java/com/codingcat/changelogs/paper/meta/PaperPluginChangelogsMeta.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.codingcat.changelogs.paper.meta; - -import com.codingcat.changelogs.platformapi.meta.ChangelogsMeta; -import io.papermc.paper.plugin.configuration.PluginMeta; -import lombok.RequiredArgsConstructor; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.HashSet; -import java.util.Set; - -@RequiredArgsConstructor -public final class PaperPluginChangelogsMeta implements ChangelogsMeta { - private final @NotNull PluginMeta pluginMeta; - - @Override - public @NotNull String getName() { - return this.pluginMeta.getName(); - } - - @Override - public @NotNull String getVersion() { - return this.pluginMeta.getVersion(); - } - - @Override - public @Nullable String getDescription() { - return this.pluginMeta.getDescription(); - } - - @Override - public @Nullable Set getAuthors() { - if (this.pluginMeta.getAuthors().isEmpty()) return null; - return new HashSet<>(this.pluginMeta.getAuthors()); - } -} diff --git a/paper/src/main/java/com/codingcat/changelogs/paper/player/PaperConfigurationPhaseConnectionWrapper.java b/paper/src/main/java/com/codingcat/changelogs/paper/player/PaperConfigurationPhaseConnectionWrapper.java deleted file mode 100644 index 6219783..0000000 --- a/paper/src/main/java/com/codingcat/changelogs/paper/player/PaperConfigurationPhaseConnectionWrapper.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.codingcat.changelogs.paper.player; - -import com.codingcat.changelogs.platformapi.player.IPlayer; -import com.destroystokyo.paper.ClientOption; -import com.github.retrooper.packetevents.PacketEvents; -import com.github.retrooper.packetevents.protocol.player.User; -import io.papermc.paper.connection.PlayerConfigurationConnection; -import lombok.RequiredArgsConstructor; -import net.kyori.adventure.audience.Audience; -import net.kyori.adventure.permission.PermissionChecker; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.util.TriState; -import org.bukkit.Bukkit; -import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Locale; -import java.util.Objects; -import java.util.UUID; - -import static net.kyori.adventure.text.Component.empty; - -@RequiredArgsConstructor -@SuppressWarnings("UnstableApiUsage") -public class PaperConfigurationPhaseConnectionWrapper implements IPlayer { - private final @NotNull PlayerConfigurationConnection connection; - - @Override - public @NotNull UUID getUniqueId() { - return Objects.requireNonNull(this.connection.getProfile().getId(), "Player UUID was null"); - } - - @Override - public @NotNull Locale getClientLocale() { - return Locale.of(this.connection.getClientOption(ClientOption.LOCALE)); - } - - @Override - public boolean isFirstJoin() { - return false; - } - - @Override - public @NotNull TriState isNativeAdmin() { - return TriState.NOT_SET; - } - - @Override - public void kick(@Nullable Component reason) { - this.connection.disconnect(reason != null ? reason : empty()); - } - - @Override - public @NotNull User asPacketEventsUser() { - Object channel = PacketEvents.getAPI().getProtocolManager().getChannel(this.getUniqueId()); - return Objects.requireNonNull(PacketEvents.getAPI().getProtocolManager().getUser(channel), "Player channel was null"); - } - - @Override - public @NotNull Audience asAudience() { - return this.connection.getAudience(); - } - - @Override - public @NotNull PermissionChecker asPermissionChecker() { - Player player = Bukkit.getPlayer(this.getUniqueId()); - return player != null ? player::permissionValue : PermissionChecker.always(TriState.NOT_SET); - } - - @Override - public @NotNull Object unwrapNative() { - return this.connection; - } -} diff --git a/paper/src/main/java/com/codingcat/changelogs/paper/player/PaperPlayerManager.java b/paper/src/main/java/com/codingcat/changelogs/paper/player/PaperPlayerManager.java deleted file mode 100644 index bde9067..0000000 --- a/paper/src/main/java/com/codingcat/changelogs/paper/player/PaperPlayerManager.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.codingcat.changelogs.paper.player; - -import com.codingcat.changelogs.platformapi.player.IPlayer; -import com.codingcat.changelogs.platformapi.player.IPlayerManager; -import lombok.RequiredArgsConstructor; -import org.bukkit.Server; -import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.stream.Collectors; - -@RequiredArgsConstructor -public class PaperPlayerManager implements IPlayerManager { - private final @NotNull Server server; - - @Override - public @NotNull Set getOnlinePlayers() { - return this.server.getOnlinePlayers() - .stream().map(this::fromNative) - .collect(Collectors.toUnmodifiableSet()); - } - - @Override - public @Nullable IPlayer getFromUUID(@NotNull UUID uuid) { - return Optional.ofNullable(this.server.getPlayer(uuid)) - .map(this::fromNative).orElse(null); - } - - @Override - public @Nullable IPlayer getFromName(@NotNull String name) { - return Optional.ofNullable(this.server.getPlayer(name)) - .map(this::fromNative).orElse(null); - } - - @Override - public @NotNull IPlayer fromNative(@NotNull Object nativePlayer) throws IllegalArgumentException { - if (!(nativePlayer instanceof Player player)) - throw new IllegalArgumentException("Expected bukkit Player but got " + nativePlayer); - return new PaperPlayerWrapper(player); - } -} diff --git a/paper/src/main/java/com/codingcat/changelogs/paper/player/PaperPlayerWrapper.java b/paper/src/main/java/com/codingcat/changelogs/paper/player/PaperPlayerWrapper.java deleted file mode 100644 index 1f09d83..0000000 --- a/paper/src/main/java/com/codingcat/changelogs/paper/player/PaperPlayerWrapper.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.codingcat.changelogs.paper.player; - -import com.codingcat.changelogs.platformapi.player.IPlayer; -import com.github.retrooper.packetevents.PacketEvents; -import com.github.retrooper.packetevents.protocol.player.User; -import lombok.RequiredArgsConstructor; -import net.kyori.adventure.audience.Audience; -import net.kyori.adventure.permission.PermissionChecker; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.util.TriState; -import org.bukkit.entity.Player; -import org.bukkit.event.player.PlayerKickEvent; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Locale; -import java.util.Objects; -import java.util.UUID; - -@RequiredArgsConstructor -public class PaperPlayerWrapper implements IPlayer { - private final @NotNull Player player; - - @Override - public @NotNull UUID getUniqueId() { - return this.player.getUniqueId(); - } - - @Override - public @NotNull Locale getClientLocale() { - return this.player.locale(); - } - - @Override - public boolean isFirstJoin() { - return !this.player.hasPlayedBefore(); - } - - @Override - public @NotNull TriState isNativeAdmin() { - return TriState.byBoolean(player.isOp()); - } - - @Override - public void kick(@Nullable Component reason) { - this.player.kick(reason, PlayerKickEvent.Cause.PLUGIN); - } - - @Override - public @NotNull User asPacketEventsUser() { - return Objects.requireNonNull(PacketEvents.getAPI().getPlayerManager().getUser(this.player), "PacketEvents player was null"); - } - - @Override - public @NotNull Audience asAudience() { - return this.player; - } - - @Override - public @NotNull PermissionChecker asPermissionChecker() { - return this.player::permissionValue; - } - - @Override - public @NotNull Object unwrapNative() { - return this.player; - } -} diff --git a/platformapi/build.gradle.kts b/platformapi/build.gradle.kts deleted file mode 100644 index ae0c32a..0000000 --- a/platformapi/build.gradle.kts +++ /dev/null @@ -1,15 +0,0 @@ -plugins { - `java-library` - alias(libs.plugins.lombok) -} - -dependencies { - compileOnly(libs.adventure.api) - compileOnly(libs.adventure.slf4j) - compileOnly(libs.mojang.brigadier) - compileOnly(libs.slf4j.api) -} - -java { - toolchain.languageVersion = JavaLanguageVersion.of(25) -} diff --git a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/ChangelogsPlatform.java b/platformapi/src/main/java/com/codingcat/changelogs/platformapi/ChangelogsPlatform.java deleted file mode 100644 index 96b395c..0000000 --- a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/ChangelogsPlatform.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.codingcat.changelogs.platformapi; - -import com.codingcat.changelogs.platformapi.command.ICommandManager; -import com.codingcat.changelogs.platformapi.event.IEventManager; -import com.codingcat.changelogs.platformapi.item.NativeItemManager; -import com.codingcat.changelogs.platformapi.meta.ChangelogsMeta; -import com.codingcat.changelogs.platformapi.meta.PlatformMeta; -import com.codingcat.changelogs.platformapi.player.IPlayerManager; -import net.kyori.adventure.text.logger.slf4j.ComponentLogger; -import org.jetbrains.annotations.NotNull; - -import java.nio.file.Path; - -public interface ChangelogsPlatform { - @NotNull ComponentLogger getComponentLogger(); - - @NotNull Path getDataPath(); - - @NotNull IPlayerManager getPlayerManager(); - - @NotNull IEventManager getEventManager(); - - @NotNull ICommandManager getCommandManager(); - - @NotNull NativeItemManager getNativeItemManager(); - - @NotNull PlatformMeta getPlatformMeta(); - - @NotNull ChangelogsMeta getChangelogsMeta(); -} diff --git a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/Entrypoint.java b/platformapi/src/main/java/com/codingcat/changelogs/platformapi/Entrypoint.java deleted file mode 100644 index a5619a0..0000000 --- a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/Entrypoint.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.codingcat.changelogs.platformapi; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.jetbrains.annotations.NotNull; - -@Getter -@RequiredArgsConstructor -public abstract class Entrypoint { - private final @NotNull ChangelogsPlatform platform; - - public abstract void onStart(); - - public abstract void onShutdown(); -} diff --git a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/IWrapper.java b/platformapi/src/main/java/com/codingcat/changelogs/platformapi/IWrapper.java deleted file mode 100644 index 928a35c..0000000 --- a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/IWrapper.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.codingcat.changelogs.platformapi; - -import org.jetbrains.annotations.NotNull; - -public interface IWrapper { - @NotNull Object unwrapNative(); -} diff --git a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/audience/IBasePermissionHolderAudienceWrapper.java b/platformapi/src/main/java/com/codingcat/changelogs/platformapi/audience/IBasePermissionHolderAudienceWrapper.java deleted file mode 100644 index 1b40b80..0000000 --- a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/audience/IBasePermissionHolderAudienceWrapper.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.codingcat.changelogs.platformapi.audience; - -import com.codingcat.changelogs.platformapi.IWrapper; -import net.kyori.adventure.audience.Audience; -import net.kyori.adventure.permission.PermissionChecker; -import net.kyori.adventure.util.TriState; -import org.jetbrains.annotations.NotNull; - -public interface IBasePermissionHolderAudienceWrapper extends IWrapper { - @NotNull Audience asAudience(); - - @NotNull PermissionChecker asPermissionChecker(); - - default boolean hasPermission(@NotNull String permission) { - return asPermissionChecker().test(permission); - } - - default boolean hasPermission(@NotNull String permission, boolean trueIfUnset) { - TriState value = asPermissionChecker().value(permission); - return value == TriState.TRUE || (trueIfUnset && value == TriState.NOT_SET); - } -} diff --git a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/command/ICommandManager.java b/platformapi/src/main/java/com/codingcat/changelogs/platformapi/command/ICommandManager.java deleted file mode 100644 index d9e3704..0000000 --- a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/command/ICommandManager.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.codingcat.changelogs.platformapi.command; - -import com.mojang.brigadier.tree.LiteralCommandNode; -import org.jetbrains.annotations.NotNull; - -import java.util.Set; - -public interface ICommandManager { - default void register(@NotNull LiteralCommandNode node) { - this.register(node, Set.of()); - } - - void register(@NotNull LiteralCommandNode node, @NotNull Set aliases); - - @NotNull ICommandSource adaptPlatformSource(@NotNull Object platformCommandSource) throws IllegalArgumentException; -} diff --git a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/command/ICommandSource.java b/platformapi/src/main/java/com/codingcat/changelogs/platformapi/command/ICommandSource.java deleted file mode 100644 index 7331e25..0000000 --- a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/command/ICommandSource.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.codingcat.changelogs.platformapi.command; - -import com.codingcat.changelogs.platformapi.audience.IBasePermissionHolderAudienceWrapper; -import com.codingcat.changelogs.platformapi.player.IPlayer; -import org.jetbrains.annotations.Nullable; - -public interface ICommandSource extends IBasePermissionHolderAudienceWrapper { - @Nullable IPlayer getExecutingPlayer(); -} diff --git a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/IEventManager.java b/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/IEventManager.java deleted file mode 100644 index 44eff1e..0000000 --- a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/IEventManager.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.codingcat.changelogs.platformapi.event; - -import com.codingcat.changelogs.platformapi.event.impl.IEvent; -import com.codingcat.changelogs.platformapi.event.util.MethodDispatchingListener; -import net.kyori.adventure.key.Key; -import org.jetbrains.annotations.NotNull; - -import java.util.function.Consumer; -import java.util.function.Predicate; - -public interface IEventManager { - default void registerMethodDispatcher(@NotNull MethodDispatchingListener methodDispatcher) { - methodDispatcher.setupAll(this); - } - - default void registerIdentified(@NotNull Class eventCls, @NotNull Key key, @NotNull Consumer listener) { - this.registerListener(eventCls, IdentifiedListener.create(key, listener)); - } - - void registerListener(@NotNull Class eventCls, @NotNull Consumer listener); - - void unregisterListener(@NotNull Consumer listener); - - default void unregisterIdentified(@NotNull Key identifier) { - this.unregisterIf(e -> e instanceof IdentifiedListener ie && ie.key().equals(identifier)); - } - - void unregisterIf(@NotNull Predicate> listenerPredicate); - - void unregisterAll(); -} diff --git a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/IdentifiedListener.java b/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/IdentifiedListener.java deleted file mode 100644 index 574c522..0000000 --- a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/IdentifiedListener.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.codingcat.changelogs.platformapi.event; - -import net.kyori.adventure.key.Key; -import net.kyori.adventure.key.Keyed; -import org.jetbrains.annotations.NotNull; - -import java.util.function.Consumer; - -public interface IdentifiedListener extends Consumer, Keyed { - static @NotNull IdentifiedListener create(@NotNull Key key, @NotNull Consumer consumer) { - return new IdentifiedListener<>() { - @Override - public void accept(T t) { - consumer.accept(t); - } - - @Override - public @NotNull Key key() { - return key; - } - }; - } -} diff --git a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/impl/IEvent.java b/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/impl/IEvent.java deleted file mode 100644 index 95bda46..0000000 --- a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/impl/IEvent.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.codingcat.changelogs.platformapi.event.impl; - -public sealed interface IEvent permits PlayerJoinEvent, PlayerEnterConfigurationPhaseEvent { -} diff --git a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/impl/PlayerEnterConfigurationPhaseEvent.java b/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/impl/PlayerEnterConfigurationPhaseEvent.java deleted file mode 100644 index fc10128..0000000 --- a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/impl/PlayerEnterConfigurationPhaseEvent.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.codingcat.changelogs.platformapi.event.impl; - -import com.codingcat.changelogs.platformapi.player.IPlayer; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.jetbrains.annotations.NotNull; - -/** - * This event should be handles asynchronously by the platform implementations, - * so that blocking this event's thread will result in a player staying in the configuration phase. - */ -@Getter -@RequiredArgsConstructor -public final class PlayerEnterConfigurationPhaseEvent implements IEvent { - private final @NotNull IPlayer player; - private final boolean initialConfiguration; -} diff --git a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/impl/PlayerJoinEvent.java b/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/impl/PlayerJoinEvent.java deleted file mode 100644 index d087f76..0000000 --- a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/impl/PlayerJoinEvent.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.codingcat.changelogs.platformapi.event.impl; - -import com.codingcat.changelogs.platformapi.player.IPlayer; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.jetbrains.annotations.NotNull; - -@Getter -@RequiredArgsConstructor -public final class PlayerJoinEvent implements IEvent { - private final @NotNull IPlayer player; - private final boolean justConnected; -} diff --git a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/util/MethodDispatchingListener.java b/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/util/MethodDispatchingListener.java deleted file mode 100644 index 4b37e1c..0000000 --- a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/util/MethodDispatchingListener.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.codingcat.changelogs.platformapi.event.util; - -import com.codingcat.changelogs.platformapi.event.IEventManager; -import com.codingcat.changelogs.platformapi.event.impl.IEvent; -import org.jetbrains.annotations.NotNull; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.util.function.Consumer; - -public interface MethodDispatchingListener { - default void setupAll(@NotNull IEventManager eventManager) throws IllegalArgumentException { - Class cls = this.getClass(); - for (Method method : cls.getDeclaredMethods()) { - if (!method.isAnnotationPresent(ListenerMethod.class)) continue; - if (method.getParameterCount() != 1) - throw new IllegalArgumentException("Listener method " + method + " has an invalid amount of parameters"); - Class paramType = method.getParameterTypes()[0]; - if (!IEvent.class.isAssignableFrom(paramType) || IEvent.class.equals(paramType)) - throw new IllegalArgumentException("Listener method " + method + " parameter has to be a valid implementation of IEvent, got " + paramType + " instead"); - IdentifiableEventConsumer handler = new IdentifiableEventConsumer<>() { - @Override - public @NotNull MethodDispatchingListener getOrigin() { - return MethodDispatchingListener.this; - } - - @Override - public void accept(IEvent iEvent) { - try { - method.invoke(MethodDispatchingListener.this, iEvent); - } catch (IllegalAccessException | InvocationTargetException e) { - throw new RuntimeException("Failed to invoke method dispatching listener method " + method + " for event " + iEvent, e); - } - } - }; - //noinspection unchecked - eventManager.registerListener((Class) paramType, handler); - } - } - - default void unregisterAll(@NotNull IEventManager eventManager) { - eventManager.unregisterIf(l -> l instanceof IdentifiableEventConsumer iec && iec.getOrigin() == this); - } - - @Target(ElementType.METHOD) - @Retention(RetentionPolicy.RUNTIME) - @interface ListenerMethod { - } - - interface IdentifiableEventConsumer extends Consumer { - @NotNull MethodDispatchingListener getOrigin(); - } -} diff --git a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappings.java b/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappings.java deleted file mode 100644 index 93c2e16..0000000 --- a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappings.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.codingcat.changelogs.platformapi.event.util; - -import com.codingcat.changelogs.platformapi.event.impl.IEvent; -import org.jetbrains.annotations.NotNull; - -import java.util.HashSet; -import java.util.Set; -import java.util.function.Function; - -public abstract class PlatformEventMappings { - private final @NotNull Set> eventMappings = new HashSet<>(); - - public record Mapping( - @NotNull Class platformEventCls, - @NotNull Class nativeEventCls, - @NotNull Function mapper - ) { - public @NotNull PL convertToPlatform(@NotNull PA nativeEvent) { - return this.mapper.apply(nativeEvent); - } - } - - protected void register(@NotNull Class platformEventCls, @NotNull Class nativeEventCls, @NotNull Function mapper) { - this.eventMappings.add(new Mapping<>(platformEventCls, nativeEventCls, mapper)); - } - - public @NotNull Mapping findForPlatformEvent(@NotNull Class eventCls) { - //noinspection unchecked - return this.eventMappings.stream() - .filter(m -> m.platformEventCls.equals(eventCls)) - .findAny().map(m -> (Mapping) m) - .orElseThrow(() -> new IllegalArgumentException("Unable to find matching native event for " + eventCls)); - } -} diff --git a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/item/NativeItemManager.java b/platformapi/src/main/java/com/codingcat/changelogs/platformapi/item/NativeItemManager.java deleted file mode 100644 index b39cedc..0000000 --- a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/item/NativeItemManager.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.codingcat.changelogs.platformapi.item; - -import net.kyori.adventure.key.Key; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -public interface NativeItemManager { - @NotNull Object adaptToPEStack(@NotNull Object nativeStack); - - @Nullable Object createNativeStack(@NotNull Key key, int amount); - - @NotNull Object applyComponentStr(@NotNull Object nativeStack, @NotNull String componentStr); -} diff --git a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/meta/ChangelogsMeta.java b/platformapi/src/main/java/com/codingcat/changelogs/platformapi/meta/ChangelogsMeta.java deleted file mode 100644 index d6e297d..0000000 --- a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/meta/ChangelogsMeta.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.codingcat.changelogs.platformapi.meta; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Set; - -public interface ChangelogsMeta { - @NotNull String getName(); - - @NotNull String getVersion(); - - @Nullable String getDescription(); - - @Nullable Set getAuthors(); -} diff --git a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/meta/PlatformMeta.java b/platformapi/src/main/java/com/codingcat/changelogs/platformapi/meta/PlatformMeta.java deleted file mode 100644 index ea4987e..0000000 --- a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/meta/PlatformMeta.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.codingcat.changelogs.platformapi.meta; - -import net.kyori.adventure.text.Component; -import org.jetbrains.annotations.NotNull; - -public interface PlatformMeta { - @NotNull Component getName(); - - @NotNull String getVersion(); -} diff --git a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/player/IPlayer.java b/platformapi/src/main/java/com/codingcat/changelogs/platformapi/player/IPlayer.java deleted file mode 100644 index 42c9c3f..0000000 --- a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/player/IPlayer.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.codingcat.changelogs.platformapi.player; - -import com.codingcat.changelogs.platformapi.audience.IBasePermissionHolderAudienceWrapper; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.util.TriState; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Locale; -import java.util.UUID; - -public interface IPlayer extends IBasePermissionHolderAudienceWrapper { - @NotNull UUID getUniqueId(); - - @NotNull Locale getClientLocale(); - - boolean isFirstJoin(); - - @NotNull TriState isNativeAdmin(); - - void kick(@Nullable Component reason); - - @NotNull Object asPacketEventsUser(); -} diff --git a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/player/IPlayerManager.java b/platformapi/src/main/java/com/codingcat/changelogs/platformapi/player/IPlayerManager.java deleted file mode 100644 index f0e0f78..0000000 --- a/platformapi/src/main/java/com/codingcat/changelogs/platformapi/player/IPlayerManager.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.codingcat.changelogs.platformapi.player; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Set; -import java.util.UUID; - -public interface IPlayerManager { - @NotNull Set getOnlinePlayers(); - - @Nullable IPlayer getFromUUID(@NotNull UUID uuid); - - @Nullable IPlayer getFromName(@NotNull String name); - - @NotNull IPlayer fromNative(@NotNull Object nativePlayer) throws IllegalArgumentException; -} diff --git a/settings.gradle.kts b/settings.gradle.kts index f5c76ca..b76a2d3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,46 @@ +@file:Suppress("UnstableApiUsage") + +// -------===={ Project Configuration }====------- + rootProject.name = "ServerChangelogs" -include("base", "platformapi", "paper", "velocity") \ No newline at end of file + +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") +enableFeaturePreview("STABLE_CONFIGURATION_CACHE") +enableFeaturePreview("GROOVY_COMPILATION_AVOIDANCE") +enableFeaturePreview("NO_IMPLICIT_LOOKUP_IN_PARENT_PROJECTS") + +// -------===={ Subprojects }====------- + +include("base") +include("platformapi") +include("paper") +include("velocity") + +rootProject.children.forEach { it.projectDir = file("src/${it.name}") } + +// -------===={ Plugin Management }====------- + +pluginManagement { + repositories { + gradlePluginPortal() + mavenLocal() + mavenCentral() + } +} + +// -------===={ Plugins }====------- + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" + id("com.gradle.develocity") version "4.6.0" +} + +// -------===={ Plugin Configuration }====------- + +develocity { + buildScan { + termsOfUseUrl = "https://gradle.com/help/legal-terms-of-use" + termsOfUseAgree = "yes" + publishing.onlyIf { true } + } +} diff --git a/src/base/build.gradle.kts b/src/base/build.gradle.kts new file mode 100644 index 0000000..1c1b125 --- /dev/null +++ b/src/base/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + `java-library` +} + +dependencies { + implementation(projects.platformapi) + api(libs.slf4k) + compileOnly(libs.snakeyaml) + compileOnly(libs.bundles.adventure) + compileOnly(libs.mojang.brigadier) + compileOnly(libs.slf4j.api) + compileOnly(libs.packetevents.api) + testImplementation(libs.kotlin.test) +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/Entrypoints.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/Entrypoints.kt new file mode 100644 index 0000000..5fd5194 --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/Entrypoints.kt @@ -0,0 +1,17 @@ +package com.codingcat.changelogs.base + +import com.codingcat.changelogs.platformapi.ChangelogsPlatform +import com.codingcat.changelogs.platformapi.Entrypoint + +/** + * Factory creating and binding the common [Entrypoints] singleton to the host [ChangelogsPlatform]. + */ +object Entrypoints { + /** + * Binds [platform] to [ServerChangelogs] and returns the singleton entrypoint. + */ + fun create(platform: ChangelogsPlatform): Entrypoint { + ServerChangelogs.platform = platform + return ServerChangelogs + } +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/ServerChangelogs.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/ServerChangelogs.kt new file mode 100644 index 0000000..0f717cf --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/ServerChangelogs.kt @@ -0,0 +1,213 @@ +package com.codingcat.changelogs.base + +import com.codingcat.changelogs.base.command.DedicatedChangelogCommand +import com.codingcat.changelogs.base.command.ServerChangelogsCommand +import com.codingcat.changelogs.base.command.register +import com.codingcat.changelogs.base.compat.PacketEventsFix +import com.codingcat.changelogs.base.config.PluginConfig +import com.codingcat.changelogs.base.data.ChangelogStorage +import com.codingcat.changelogs.base.dialog.DialogSessionManager +import com.codingcat.changelogs.base.dialog.PluginDialog +import com.codingcat.changelogs.base.event.ChangelogJoinListener +import com.codingcat.changelogs.base.lang.TranslationSource +import com.codingcat.changelogs.base.lang.TranslationSource.Companion.translatable +import com.codingcat.changelogs.base.util.ResourceUtil +import com.codingcat.changelogs.platformapi.ChangelogsPlatform +import com.codingcat.changelogs.platformapi.Entrypoint +import com.codingcat.changelogs.platformapi.command.PlatformCommandManager +import com.github.retrooper.packetevents.PacketEvents +import com.github.retrooper.packetevents.PacketEventsAPI +import net.kyori.adventure.key.Key +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.ComponentLike +import net.kyori.adventure.text.logger.slf4j.ComponentLogger +import net.kyori.adventure.translation.GlobalTranslator +import org.slf4j.kotlin.KLogger +import org.slf4j.kotlin.info +import org.slf4j.kotlin.warn +import org.yaml.snakeyaml.error.YAMLException +import java.io.IOException +import java.nio.file.Path +import java.util.* +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.writeText + +/** + * Core entrypoint and central coordinator for ServerChangelogs plugin state, configuration, and services. + */ +object ServerChangelogs : Entrypoint { + override lateinit var platform: ChangelogsPlatform + + override val packetEventsApi: PacketEventsAPI by lazy { PacketEvents.getAPI() } + + /** + * Adventure key namespace for ServerChangelogs identifiers. + */ + const val NAMESPACE: String = "server_changelogs" + + /** + * Constructs a namespaced Adventure key for ServerChangelogs identifiers. + */ + fun key(path: String): Key = Key.key(NAMESPACE, path) + + /** + * Key generator function for namespaced Adventure keys. + */ + @JvmField + val KEY_GENERATOR: (String) -> Key = ::key + + /** + * Active storage engine managing changelog persistence and reads. + */ + lateinit var changelogStorage: ChangelogStorage + private set + + /** + * Container holding UI dialog definitions. + */ + val dialogHolder: PluginDialog.Holder = PluginDialog.Holder + + /** + * Session manager tracking active dialog interactions and packet events. + */ + val dialogSessionManager: DialogSessionManager = DialogSessionManager + + private lateinit var translationSource: TranslationSource + private lateinit var config: PluginConfig + private lateinit var componentLogger: ComponentLogger + lateinit var logger: KLogger + private set + + override fun onStart() { + componentLogger = platform.getComponentLogger() + logger = KLogger(componentLogger) + + val translationPath: Path = platform.getDataPath().resolve("lang") + if (!translationPath.exists()) { + runCatching { translationPath.createDirectories() } + .onFailure { e -> logger.warn(e) { "Failed to create translation directory:" } } + logger.info { "Creating default translation files..." } + ResourceUtil.readResourcesAsString("lang").forEach { (fname, contents) -> + try { + translationPath.resolve(fname).writeText(contents) + } catch (e: IOException) { + logger.warn(e) { "Failed to create default translation file \"${fname}\":" } + } + } + } + this.translationSource = TranslationSource( + translationPath, + platform.changelogsMeta, + platform.platformMeta, + logger, + ) + this.translationSource.reload() + + info("console.startup") + + val configPath: Path = platform.getDataPath().resolve("config.yml") + if (!configPath.exists()) { + logger.info { "Creating default configuration file..." } + val defaultConfig: String = ResourceUtil.readResourceAsString("defaults/config.yml") + try { + configPath.writeText(defaultConfig) + } catch (e: IOException) { + logger.warn(e) { "Failed to create default config file \"${configPath}\":" } + } + } + this.config = PluginConfig(configPath) + this.config.tryReload() + this.changelogStorage = this.config.createChangelogStorage() + info( + "console.startup_storage", + Component.text(this.changelogStorage.displayName), + ) + this.changelogStorage.init() + PacketEventsFix.setManualWorkarounds(config.enabledManualWorkarounds, logger) + this.dialogHolder.recreate() + this.dialogSessionManager.registerEvents() + ChangelogJoinListener.registerEvents(platform.eventManager) + this.registerCommands(platform.commandManager) + } + + private fun registerCommands(commandManager: PlatformCommandManager) { + commandManager.register(ServerChangelogsCommand, this) + if (this.config.registerDedicatedCommand()) { + commandManager.register(DedicatedChangelogCommand, this) + } + } + + /** + * Reloads configuration, translations, dialogs, and changelog storage backends. + * + * @param force when true, forces dialog reloading even if active dialog editors reject dismissal. + * @throws IOException if configuration or translation files fail to read. + * @throws YAMLException if configuration syntax validation fails. + * @throws PluginDialog.DestroyRejectedException if an active dialog rejects reload and [force] is false. + */ + @Throws(IOException::class, YAMLException::class, PluginDialog.DestroyRejectedException::class) + fun reload(force: Boolean) { + info("console.reload") + if (!force) this.dialogHolder.ensureCanReload() + this.translationSource.reload() + this.config.reload() + this.config.validate()?.let { err -> + throw YAMLException(err) + } + this.changelogStorage.shutdown() + this.changelogStorage = this.config.createChangelogStorage() + info( + "console.startup_storage", + Component.text(this.changelogStorage.displayName), + ) + this.changelogStorage.init() + PacketEventsFix.setManualWorkarounds(config.enabledManualWorkarounds, logger) + this.dialogHolder.recreate() + val eventManager = platform.eventManager + ChangelogJoinListener.unregisterEvents(eventManager) + ChangelogJoinListener.registerEvents(eventManager) + } + + override fun onShutdown() { + info("console.shutdown") + this.dialogSessionManager.unregisterEvents() + ChangelogJoinListener.unregisterEvents(platform.eventManager) + this.changelogStorage.shutdown() + } + + /** + * Active configuration settings instance. + */ + fun pluginConfig(): PluginConfig = this.config + + /** + * Logs an informational message translated for the console locale. + */ + fun info(key: String, vararg args: ComponentLike) { + componentLogger.info(translateConsole(key, *args)) + } + + /** + * Logs a warning message translated for the console locale. + */ + fun warn(key: String, vararg args: ComponentLike) { + componentLogger.warn(translateConsole(key, *args)) + } + + /** + * Logs an error message and exception translated for the console locale. + */ + fun error(key: String, e: Throwable, vararg args: ComponentLike) { + componentLogger.error(translateConsole(key, *args), e) + } + + private fun translateConsole( + key: String, + vararg args: ComponentLike, + ): Component { + val value: Component? = GlobalTranslator.translator() + .translate(translatable(key, *args), Locale.US) + return value ?: Component.text(key) + } +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/AbstractCommand.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/AbstractCommand.kt new file mode 100644 index 0000000..f671a7a --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/AbstractCommand.kt @@ -0,0 +1,232 @@ +package com.codingcat.changelogs.base.command + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.base.command.argument.PlayerArgument +import com.codingcat.changelogs.base.lang.TranslationSource +import com.codingcat.changelogs.platformapi.command.PlatformCommandSource +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.mojang.brigadier.Command +import com.mojang.brigadier.arguments.ArgumentType +import com.mojang.brigadier.builder.ArgumentBuilder +import com.mojang.brigadier.builder.LiteralArgumentBuilder +import com.mojang.brigadier.builder.RequiredArgumentBuilder +import com.mojang.brigadier.context.CommandContext +import com.mojang.brigadier.tree.LiteralCommandNode +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor + +/** + * Base class for commands across platforms, providing standardized registration and helper utilities. + * + * Implements [CommandBuilder] and handles: + * - Permission checking via Brigadier requirements. + * - Feature flag validation to disable commands if inactive. + * - Player-only execution validation via [executesPlayer]. + * - Fluent argument and literal builder extensions. + * + * @property name The literal name of the command. + * @property description Brief description of the command. + * @property usage Usage string for the command, defaulting to `"/${name}"`. + * @property aliases Alternate labels for command invocation. + * @property permission The required permission key (without namespace prefix if relative), or null if unrestricted. + * @property permissionEnabledByDefault Whether the permission is granted by default when unset. + * @property featureEnabled Whether the command's feature is globally enabled. + * + * @author GuavaDealer + * @since Kotlin Migration + */ +abstract class AbstractCommand( + val name: String, + override val description: String, + val usage: String = "/${name}", + override val aliases: List = emptyList(), + val permission: String? = null, + val permissionEnabledByDefault: Boolean = false, + val featureEnabled: Boolean = true, +) : CommandBuilder { + /** + * Active plugin instance assigned during [build]. + */ + protected lateinit var plugin: ServerChangelogs + private set + + override fun build(plugin: ServerChangelogs): LiteralCommandNode { + this.plugin = plugin + if (!isFeatureEnabled(plugin)) { + return LiteralArgumentBuilder.literal(name) + .executes { ctx -> + val source = plugin.platform.commandManager.adaptPlatformSource(ctx.source) + source.asAudience().sendMessage( + Component.text("This feature is disabled.", NamedTextColor.RED), + ) + Command.SINGLE_SUCCESS + } + .build() + } + + val builder = LiteralArgumentBuilder.literal(name) + + permission?.let { permStr -> + val perm = permStr.removePrefix("${ServerChangelogs.NAMESPACE}.") + val predicate = BrigadierCommandNode.requirePermission( + perm, + plugin, + permissionEnabledByDefault, + ) + builder.requires { predicate(it) } + } + + buildCommand(builder) + + return builder.build() + } + + /** + * Determines whether this command is currently enabled for registration. + * + * @param plugin The active [ServerChangelogs] instance. + * @return `true` if enabled, `false` otherwise. + */ + open fun isFeatureEnabled(plugin: ServerChangelogs): Boolean = featureEnabled + + /** + * Builds the command structure and execution handlers. + * + * @param builder The literal argument builder being configured. + */ + abstract fun buildCommand(builder: LiteralArgumentBuilder) + + /** + * Executes the given block only if the invoking source represents a player. + * + * @param block The block invoked with the executing player and command context. + * @return The updated argument builder. + */ + protected fun > T.executesPlayer( + block: (PlatformPlayer, CommandContext) -> Unit, + ): T { + return this.executes { ctx -> + val source = plugin.platform.commandManager.adaptPlatformSource(ctx.source) + val player = source.executingPlayer ?: run { + source.asAudience().sendMessage(TranslationSource.translatable("command.onlyplayer")) + return@executes Command.SINGLE_SUCCESS + } + block(player, ctx) + Command.SINGLE_SUCCESS + } + } + + /** + * Executes the given block with the adapted [PlatformCommandSource] and command context. + * + * @param block The block invoked with the executing command source and command context. + * @return The updated argument builder. + */ + protected fun > T.executesSource( + block: (PlatformCommandSource, CommandContext) -> Unit, + ): T { + return this.executes { ctx -> + val source = plugin.platform.commandManager.adaptPlatformSource(ctx.source) + block(source, ctx) + Command.SINGLE_SUCCESS + } + } + + /** + * Attaches a permission requirement to this argument builder. + * + * @param permission The permission node to require. + * @param trueIfUnset Whether permission is granted if unset. + * @return The updated argument builder. + */ + protected fun > T.requiresPermission( + permission: String, + trueIfUnset: Boolean = false, + ): T { + val perm = permission.removePrefix("${ServerChangelogs.NAMESPACE}.") + val predicate = BrigadierCommandNode.requirePermission(perm, plugin, trueIfUnset) + return this.requires { predicate(it) } + } + + /** + * Appends a child subcommand node built from a [BrigadierCommandNode]. + * + * @param command The subcommand builder to register. + * @return The updated argument builder. + */ + protected fun > S.subcommand( + command: BrigadierCommandNode, + ): S { + this.then(command.build(plugin)) + return this + } + + /** + * Appends a typed argument to this builder. + * + * @param name The argument name. + * @param argument The argument type. + * @param block Configuration block for the argument builder. + * @return The updated argument builder. + */ + protected fun , T> S.argument( + name: String, + argument: ArgumentType, + block: (@CommandDsl RequiredArgumentBuilder).() -> Unit = {}, + ): S { + val argBuilder = RequiredArgumentBuilder.argument(name, argument) + argBuilder.block() + this.then(argBuilder) + return this + } + + /** + * Appends a [PlatformPlayer] argument to this builder. + * + * @param name The argument name (defaults to `"player"`). + * @param block Configuration block for the player argument builder. + * @return The updated argument builder. + */ + protected fun > S.player( + name: String = "player", + block: (@CommandDsl RequiredArgumentBuilder).() -> Unit = {}, + ): S { + return argument(name, PlayerArgument.player(plugin), block) + } + + /** + * Appends a literal subcommand branch to this builder. + * + * @param name The literal subcommand name. + * @param block Configuration block for the literal argument builder. + * @return The updated argument builder. + */ + protected fun > S.literal( + name: String, + block: (@CommandDsl LiteralArgumentBuilder).() -> Unit = {}, + ): S { + val literalBuilder = LiteralArgumentBuilder.literal(name) + literalBuilder.block() + this.then(literalBuilder) + return this + } + + /** + * Resolves the [PlatformCommandSource] from a [CommandContext]. + */ + protected val CommandContext.platformSource: PlatformCommandSource + get() = plugin.platform.commandManager.adaptPlatformSource(this.source) + + /** + * Resolves the executing [PlatformPlayer] from a [CommandContext], if the sender is a player. + */ + protected val CommandContext.executingPlayer: PlatformPlayer? + get() = platformSource.executingPlayer + + /** + * Resolves a parsed [PlatformPlayer] argument from a [CommandContext]. + */ + protected fun CommandContext.getPlayer(name: String = "player"): PlatformPlayer { + return PlayerArgument.getPlayer(this, name) + } +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/BrigadierCommandNode.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/BrigadierCommandNode.kt new file mode 100644 index 0000000..758537b --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/BrigadierCommandNode.kt @@ -0,0 +1,46 @@ +package com.codingcat.changelogs.base.command + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.platformapi.command.PlatformCommandManager +import com.mojang.brigadier.tree.LiteralCommandNode + +/** + * Functional interface for building a Brigadier [LiteralCommandNode] within [ServerChangelogs]. + */ +fun interface BrigadierCommandNode { + /** + * Constructs the literal command node for this command structure. + * + * @param plugin The active [ServerChangelogs] instance. + * @return The constructed [LiteralCommandNode]. + */ + fun build(plugin: ServerChangelogs): LiteralCommandNode + + companion object { + /** + * Creates a permission predicate lambda for Brigadier commands. + * + * @param permission The sub-permission node to check. + * @param plugin The active [ServerChangelogs] instance. + * @param trueIfUnset Whether permission check succeeds if unset. + * @return A predicate lambda returning `true` if the source has permission. + */ + fun requirePermission( + permission: String, + plugin: ServerChangelogs, + trueIfUnset: Boolean, + ): (Any) -> Boolean { + val commandManager: PlatformCommandManager = plugin.platform.commandManager + if (plugin.pluginConfig().useNativeFallbackPermissions()) { + return { source -> + val player = commandManager.adaptPlatformSource(source).executingPlayer + player?.let { it.isNativeAdmin.toBooleanOrElse(false) || trueIfUnset } ?: true + } + } + val perm = "${ServerChangelogs.NAMESPACE}.${permission}" + return { source -> + commandManager.adaptPlatformSource(source).hasPermission(perm, trueIfUnset) + } + } + } +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/CommandBuilder.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/CommandBuilder.kt new file mode 100644 index 0000000..8404c9b --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/CommandBuilder.kt @@ -0,0 +1,42 @@ +package com.codingcat.changelogs.base.command + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.platformapi.command.PlatformCommandManager +import com.mojang.brigadier.tree.LiteralCommandNode +import kotlinx.collections.immutable.toPersistentSet + +/** + * Contract for building and registering Brigadier commands across platforms. + * + * @author GuavaDealer + * @since Kotlin Migration + */ +interface CommandBuilder : BrigadierCommandNode { + /** + * Builds the Brigadier command node for registration. + * + * @param plugin The active [ServerChangelogs] instance. + * @return The built [LiteralCommandNode] with platform-agnostic source type. + */ + override fun build(plugin: ServerChangelogs): LiteralCommandNode + + /** + * Command aliases for registration. + */ + val aliases: List + get() = emptyList() + + /** + * Brief description of the command. + */ + val description: String + get() = "" +} + +/** + * Convenience extension to build and register a [CommandBuilder] alongside its declared aliases. + */ +fun PlatformCommandManager.register(builder: CommandBuilder, plugin: ServerChangelogs) { + this.register(builder.build(plugin), builder.aliases.toPersistentSet()) +} + diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/CommandDsl.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/CommandDsl.kt new file mode 100644 index 0000000..8fbe050 --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/CommandDsl.kt @@ -0,0 +1,12 @@ +package com.codingcat.changelogs.base.command + +/** + * DSL marker for Brigadier command building blocks, preventing scope leakage in nested builders. + * + * @author GuavaDealer + * @since Kotlin Migration + */ +@DslMarker +@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE, AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.BINARY) +annotation class CommandDsl diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/ServerChangelogsCommand.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/ServerChangelogsCommand.kt new file mode 100644 index 0000000..0de7793 --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/ServerChangelogsCommand.kt @@ -0,0 +1,111 @@ +package com.codingcat.changelogs.base.command + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.base.dialog.DialogPackets +import com.codingcat.changelogs.base.dialog.PluginDialog +import com.codingcat.changelogs.base.dialog.ui.ChangelogDialog +import com.codingcat.changelogs.base.dialog.ui.editor.ChangelogEditorDialog +import com.codingcat.changelogs.base.lang.TranslationSource +import com.codingcat.changelogs.platformapi.command.PlatformCommandSource +import com.mojang.brigadier.Command +import com.mojang.brigadier.builder.LiteralArgumentBuilder +import net.kyori.adventure.text.Component +import org.yaml.snakeyaml.error.YAMLException +import java.io.IOException + +/** + * Root command for ServerChangelogs (`/server_changelogs`, `/changelogs`, `/scl`). + * + * Hosts all child subcommands (`create`, `view`, `reload`, `info`) configured within + * the primary command construction block. + * + * @author GuavaDealer + * @since Kotlin Migration + */ +object ServerChangelogsCommand : AbstractCommand( + name = ServerChangelogs.NAMESPACE, + description = "Root command for ServerChangelogs", + aliases = listOf("changelogs", "scl"), +) { + override fun buildCommand(builder: LiteralArgumentBuilder) { + builder.executesSource { source, _ -> + source.asAudience().sendMessage(TranslationSource.translatable("command.root")) + } + + builder.literal("create") { + requiresPermission("command.create", false) + executesPlayer { player, _ -> + plugin.dialogHolder.getFromType() + .showTo(player, plugin.dialogSessionManager, DialogPackets.PacketPhase.PLAY) + } + } + + builder.literal("view") { + requiresPermission("command.view", true) + executesPlayer { player, _ -> + plugin.dialogHolder.getFromType() + .showTo(player, plugin.dialogSessionManager, DialogPackets.PacketPhase.PLAY) + } + } + + builder.literal("reload") { + requiresPermission("command.reload") + executesSource { source, _ -> executeReload(source, false) } + literal("--force") { + executesSource { source, _ -> executeReload(source, true) } + } + } + + builder.literal("info") { + requiresPermission("command.info", true) + executesSource { source, _ -> + source.asAudience().sendMessage(TranslationSource.translatable("command.root")) + } + } + } + + private fun executeReload(source: PlatformCommandSource, force: Boolean): Int { + val audience = source.asAudience() + val time = System.currentTimeMillis() + var errorMsg: Component? = null + try { + plugin.reload(force) + val took = System.currentTimeMillis() - time + audience.sendMessage( + TranslationSource.translatable( + "command.reload.success", + Component.text(took), + ), + ) + } catch (e: IOException) { + errorMsg = TranslationSource.translatable("command.reload.error.io") + ServerChangelogs.error("command.reload.error.details", e) + } catch (e: YAMLException) { + errorMsg = TranslationSource.translatable( + "command.reload.error.invalid", + Component.text(e.message ?: "Unknown YAML error"), + ) + } catch (e: PluginDialog.DestroyRejectedException) { + errorMsg = TranslationSource.translatable("command.reload.error.dialog_reject.${e.key}") + } + errorMsg?.let(audience::sendMessage) + return Command.SINGLE_SUCCESS + } +} + +/** + * Dedicated root command (`/changelog`) displaying the changelog view dialog directly. + */ +object DedicatedChangelogCommand : AbstractCommand( + name = "changelog", + description = "Displays the server changelog dialog", + permission = "dedicated_command", + permissionEnabledByDefault = true, +) { + override fun buildCommand(builder: LiteralArgumentBuilder) { + builder.executesPlayer { player, _ -> + plugin.dialogHolder.getFromType() + .showTo(player, plugin.dialogSessionManager, DialogPackets.PacketPhase.PLAY) + } + } +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/argument/PlayerArgument.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/argument/PlayerArgument.kt new file mode 100644 index 0000000..4b69f49 --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/argument/PlayerArgument.kt @@ -0,0 +1,100 @@ +package com.codingcat.changelogs.base.command.argument + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.codingcat.changelogs.platformapi.player.PlatformPlayerManager +import com.mojang.brigadier.LiteralMessage +import com.mojang.brigadier.StringReader +import com.mojang.brigadier.arguments.ArgumentType +import com.mojang.brigadier.context.CommandContext +import com.mojang.brigadier.exceptions.DynamicCommandExceptionType +import com.mojang.brigadier.suggestion.Suggestions +import com.mojang.brigadier.suggestion.SuggestionsBuilder +import java.util.concurrent.CompletableFuture + +/** + * Brigadier [ArgumentType] parsing online [PlatformPlayer] instances by their username. + * + * @property playerManager The platform player manager queried for active players. + * @author GuavaDealer + * @since Kotlin Migration + */ +class PlayerArgument( + private val playerManager: PlatformPlayerManager, +) : ArgumentType { + private val invalidNameException = DynamicCommandExceptionType { name -> + LiteralMessage("Invalid player name: ${name}") + } + + private val playerNotFoundException = DynamicCommandExceptionType { name -> + LiteralMessage("Player not found: ${name}") + } + + /** + * Parses a player name from the reader and resolves the corresponding connected [PlatformPlayer]. + * + * @param reader The string reader reading command input. + * @return The resolved online player. + * @throws com.mojang.brigadier.exceptions.CommandSyntaxException if the name is invalid or player not found. + */ + @Throws(com.mojang.brigadier.exceptions.CommandSyntaxException::class) + override fun parse(reader: StringReader): PlatformPlayer { + val start = reader.cursor + val argument = reader.readUnquotedString() + + if (!argument.matches(VALID_NAME_REGEX)) { + reader.cursor = start + throw invalidNameException.createWithContext(reader, argument) + } + + return playerManager[argument] ?: run { + reader.cursor = start + throw playerNotFoundException.createWithContext(reader, argument) + } + } + + /** + * Populates autocompletion suggestions matching the prefix of currently connected player names. + * + * @param context The command execution context. + * @param builder The suggestions builder accumulating completion candidates. + * @return A future completing with resolved suggestions. + */ + override fun listSuggestions( + context: CommandContext, + builder: SuggestionsBuilder, + ): CompletableFuture { + val remaining = builder.remaining.lowercase() + playerManager.onlinePlayers.forEach { player -> + if (player.name.lowercase().startsWith(remaining)) { + builder.suggest(player.name) + } + } + return builder.buildFuture() + } + + companion object { + private val VALID_NAME_REGEX = Regex("^[a-zA-Z0-9_]{3,16}$") + + /** + * Creates a new [PlayerArgument] backed by the given [PlatformPlayerManager]. + */ + fun player(playerManager: PlatformPlayerManager): PlayerArgument = PlayerArgument(playerManager) + + /** + * Creates a new [PlayerArgument] backed by the given [ServerChangelogs] plugin instance. + */ + fun player(plugin: ServerChangelogs): PlayerArgument = PlayerArgument(plugin.platform.playerManager) + + /** + * Resolves a parsed [PlatformPlayer] from a Brigadier [CommandContext]. + * + * @param context The command context. + * @param name The argument name. + * @return The parsed [PlatformPlayer]. + */ + fun getPlayer(context: CommandContext<*>, name: String): PlatformPlayer { + return context.getArgument(name, PlatformPlayer::class.java) + } + } +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/compat/PacketEventsFix.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/compat/PacketEventsFix.kt new file mode 100644 index 0000000..f2dae53 --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/compat/PacketEventsFix.kt @@ -0,0 +1,87 @@ +package com.codingcat.changelogs.base.compat + +import com.codingcat.changelogs.base.ServerChangelogs +import com.github.retrooper.packetevents.manager.server.ServerVersion +import com.github.retrooper.packetevents.protocol.component.ComponentTypes +import com.github.retrooper.packetevents.protocol.component.builtin.item.FoodProperties +import com.github.retrooper.packetevents.protocol.dialog.body.ItemDialogBody +import com.github.retrooper.packetevents.util.PEVersion +import org.slf4j.kotlin.KLogger +import org.slf4j.kotlin.warn + +private const val PE_MAJOR: Int = 2 +private const val PE_MINOR: Int = 13 +private const val PE_PATCH: Int = 1 +private const val DUMMY_NUTRITION: Int = 123 +private const val DUMMY_SATURATION_MODIFIER: Float = 123f + +/** + * This class introduces fixes for versions of PacketEvents that cause problems with the dialog system. + */ +object PacketEventsFix { + private val enabledWorkarounds: MutableSet = mutableSetOf() + + enum class Workaround( + private val versionPredicate: (PEVersion, ServerVersion) -> Boolean, + ) { + // https://github.com/retrooper/packetevents/issues/1569 - until v2.13.0 on MC versions < 26.1 + INVALID_ITEM_BODY_ENCODING( + { p, s -> + p.isOlderThan(PEVersion(PE_MAJOR, PE_MINOR, PE_PATCH)) && s.isOlderThan(ServerVersion.V_26_1) + }, + ); + + val isAffected: Boolean + get() { + val peVersion: PEVersion = ServerChangelogs.packetEventsApi.version + val serverVersion: ServerVersion = ServerChangelogs.packetEventsApi.serverManager.version + return this.versionPredicate(peVersion, serverVersion) + } + } + + fun setManualWorkarounds(manualWorkarounds: Set, logger: KLogger) { + checkAndLoad(logger) + enabledWorkarounds.addAll(manualWorkarounds) + if (manualWorkarounds.isNotEmpty()) { + logger.warn { + buildString { + append("Manual workarounds for PacketEvents issues have been enabled. ") + append("This may cause unintended behavior if activated by accident!") + } + } + val workaroundNames = manualWorkarounds.joinToString(", ") { it.name } + logger.warn { "Enabled manual workarounds: ${workaroundNames}" } + } + } + + fun checkAndLoad(logger: KLogger) { + enabledWorkarounds.clear() + Workaround.entries.filterTo(enabledWorkarounds) { it.isAffected } + if (enabledWorkarounds.isNotEmpty()) { + logger.warn { + buildString { + append("You are using a version of PacketEvents that contains known problems. ") + append("The plugin will automatically work around those issues, ") + append("but updating to the latest version is recommended!") + } + } + val workaroundNames = enabledWorkarounds.joinToString(", ") { it.name } + logger.warn { "Enabled workarounds: ${workaroundNames}" } + } + } + + fun isEnabled(workaround: Workaround): Boolean { + return enabledWorkarounds.contains(workaround) + } + + fun fixItemBody(itemBody: ItemDialogBody) { + if (!isEnabled(Workaround.INVALID_ITEM_BODY_ENCODING)) return + val item = itemBody.item + if (item.hasComponentPatches()) return + // Add dummy component data which won't affect the item visually + item.setComponent( + ComponentTypes.FOOD, + FoodProperties(DUMMY_NUTRITION, DUMMY_SATURATION_MODIFIER, false), + ) + } +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/compat/PacketEventsNativeItemManager.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/compat/PacketEventsNativeItemManager.kt new file mode 100644 index 0000000..e45232f --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/compat/PacketEventsNativeItemManager.kt @@ -0,0 +1,25 @@ +package com.codingcat.changelogs.base.compat + +import com.codingcat.changelogs.platformapi.item.NativeItemManager +import com.github.retrooper.packetevents.protocol.item.ItemStack +import com.github.retrooper.packetevents.protocol.item.type.ItemTypes +import com.github.retrooper.packetevents.resources.ResourceLocation +import net.kyori.adventure.key.Key + +object PacketEventsNativeItemManager : NativeItemManager { + @Throws(IllegalArgumentException::class) + override fun adaptToPEStack(nativeStack: Any): ItemStack { + require(nativeStack is ItemStack) { "Expected PacketEvents ItemStack but got ${nativeStack}" } + return nativeStack + } + + override fun createNativeStack(key: Key, amount: Int): Any? { + val type = ItemTypes.getRegistry().getByName(ResourceLocation(key)) + return type?.let { ItemStack.builder().type(it).amount(amount).build() } + } + + override fun applyComponentStr(nativeStack: Any, componentStr: String): Any { + // TODO: Implement component parsing + return nativeStack + } +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt new file mode 100644 index 0000000..e6b9177 --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt @@ -0,0 +1,138 @@ +package com.codingcat.changelogs.base.config + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.base.compat.PacketEventsFix +import com.codingcat.changelogs.base.data.ChangelogStorage +import com.codingcat.changelogs.base.dialog.DialogPackets +import com.github.retrooper.packetevents.protocol.item.ItemStack +import net.kyori.adventure.key.Key +import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.error.YAMLException +import java.io.IOException +import java.nio.file.Path +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.io.path.inputStream + +/** + * YAML configuration parser and validator managing plugin behavior, item stacks, and storage options. + */ +@Suppress("UNCHECKED_CAST") +class PluginConfig( + private val path: Path, +) { + private val yaml: Yaml = Yaml() + private var data: Map = emptyMap() + + /** + * Attempts to reload and validate the configuration file, throwing [RuntimeException] on failure. + */ + fun tryReload() { + runCatching { + this.reload() + }.onFailure { e -> + throw RuntimeException("Failed to reload configuration", e) + } + this.validate()?.let { err -> + throw RuntimeException("Configuration invalid: $err") + } + } + + @Throws(IOException::class, YAMLException::class) + fun reload() { + this.data = this.path.inputStream().use { stream -> + this.yaml.load(stream) + } ?: emptyMap() + } + + fun validate(): String? = runCatching { + this.dialogPacketPhase + createChangelogStorage() + this.dateFormatter + createChangelogHeaderStack() + this.enabledManualWorkarounds + null + }.getOrElse { it.message } + + @Throws(RuntimeException::class) + fun createChangelogStorage(): ChangelogStorage { + return ChangelogStorage.create(getString("changelog_storage", "yaml")) + } + + val dateFormatter: DateTimeFormatter + get() = DateTimeFormatter.ofPattern(getString("date_format", "----")) + .withZone(ZoneId.of(getString("date_timezone", "UTC"))) + + fun registerDedicatedCommand(): Boolean { + return getBoolean("register_dedicated_command", true) + } + + val dialogPacketPhase: DialogPackets.PacketPhase + get() { + val rawPhase = getString("dialog_phase", DialogPackets.PacketPhase.PLAY.name) + return runCatching { + DialogPackets.PacketPhase.valueOf(rawPhase.uppercase()) + }.getOrElse { + throw IllegalArgumentException("Invalid dialog phase \"${rawPhase}\"") + } + } + + val enabledManualWorkarounds: Set + get() { + val rawWorkarounds = getList("enable_manual_workarounds") + return rawWorkarounds.mapNotNull { workaround -> + workaround?.let { it -> + runCatching { + PacketEventsFix.Workaround.valueOf(it.uppercase()) + }.getOrElse { + throw IllegalArgumentException("Invalid manual workaround ID \"${it}\"") + } + } + }.toSet() + } + + fun showChangelogHeader(): Boolean { + return getBoolean("dialog_header", true) + } + + fun useNativeFallbackPermissions(): Boolean { + return getBoolean("use_native_fallback_permissions", false) + } + + fun createChangelogHeaderStack(): ItemStack? = + getStringOrNull("dialog_header_item")?.let(::createStack) + + private fun getString(key: String, defaultValue: String): String { + return (this.data[key] as? String) ?: defaultValue + } + + private fun getStringOrNull(key: String): String? { + return this.data[key] as? String + } + + private fun getBoolean(key: String, defaultValue: Boolean): Boolean { + return (this.data[key] as? Boolean) ?: defaultValue + } + + private inline fun getList(key: String): List { + val list = this.data[key] as? List<*> ?: return emptyList() + return list.map { it as? T } + } + + private fun createStack(input: String): ItemStack { + val idPart = if (input.contains("[")) input.substring(0, input.indexOf('[')) else input + val componentPart = if (input.contains("[")) input.substring(input.indexOf('[')) else null + val inputKey = runCatching { + Key.key(idPart) + }.getOrElse { e -> + throw RuntimeException("Invalid item ID \"${idPart}\"", e) + } + val nativeManager = ServerChangelogs.platform.nativeItemManager + var nativeStack: Any = nativeManager.createNativeStack(inputKey, 1) + ?: throw NullPointerException("Unknown item ID \"${idPart}\"") + componentPart?.let { + nativeStack = nativeManager.applyComponentStr(nativeStack, it) + } + return nativeManager.adaptToPEStack(nativeStack) + } +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogEntry.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogEntry.kt new file mode 100644 index 0000000..3fc80b2 --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogEntry.kt @@ -0,0 +1,25 @@ +package com.codingcat.changelogs.base.data + +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import net.kyori.adventure.text.Component +import java.time.Instant +import java.util.* + +/** + * Immutable record representing a single changelog post with its content, timestamp, author, and read receipts. + */ +@JvmRecord +data class ChangelogEntry( + val uid: Int, + val lines: List, + val recordedAt: Instant, + val author: Component?, + val playersRead: MutableSet, +) { + /** + * Checks whether [player] has already marked this entry as read. + */ + fun hasRead(player: PlatformPlayer): Boolean { + return this.playersRead.contains(player.uniqueId) + } +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogStorage.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogStorage.kt new file mode 100644 index 0000000..620c942 --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogStorage.kt @@ -0,0 +1,92 @@ +package com.codingcat.changelogs.base.data + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.base.data.storage.YamlChangelogStorage +import java.util.* + +/** + * Persistence abstraction for storing, updating, reading, and indexing [ChangelogEntry] items. + */ +interface ChangelogStorage : Iterable { + /** + * Initializes storage structures, loading records into memory. + */ + fun init() + + /** + * Flushes modified records to persistent storage and closes resources. + */ + fun shutdown() + + /** + * Persists a new changelog [entry]. + */ + fun storeEntry(entry: ChangelogEntry) + + /** + * Updates an existing changelog [entry] matching its unique ID. + */ + fun updateEntry(entry: ChangelogEntry) + + /** + * Deletes the changelog entry identified by [uid]. Returns true if an entry was removed. + */ + fun removeEntry(uid: Int): Boolean + + /** + * Returns an ordered snapshot list of all stored changelog entries. + */ + fun listEntries(): List + + /** + * Finds a changelog entry by its [uid], returning `null` if not found. + */ + fun getByUID(uid: Int): ChangelogEntry? + + /** + * Marks the entry with [uid] as acknowledged/read by [player]. + */ + fun markAsRead(uid: Int, player: UUID) + + /** + * Computes the next available unique identifier for a new entry. + */ + fun nextUID(): Int + + /** + * User-facing display name of this storage engine. + */ + val displayName: String + + override fun iterator(): Iterator = listEntries().iterator() + + /** + * Index operator lookup by [uid]. + */ + operator fun get(uid: Int): ChangelogEntry? = getByUID(uid) + + /** + * Checks if an entry with [uid] exists in storage. + */ + operator fun contains(uid: Int): Boolean = getByUID(uid) != null + + /** + * Appends an [entry] using `+=` operator syntax. + */ + operator fun plusAssign(entry: ChangelogEntry) { + storeEntry(entry) + } + + companion object { + /** + * Resolves and instantiates a [ChangelogStorage] backend for the requested [identifier]. + * + * @throws IllegalArgumentException if the storage type identifier is unsupported. + */ + @Throws(IllegalArgumentException::class) + fun create(identifier: String): ChangelogStorage { + require(identifier == "yaml") { "Unknown changelog storage type \"${identifier}\"" } + return YamlChangelogStorage(ServerChangelogs.platform.getDataPath().resolve("_data.yml")) + } + } +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.kt new file mode 100644 index 0000000..7fc321d --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.kt @@ -0,0 +1,174 @@ +package com.codingcat.changelogs.base.data.storage + +import com.codingcat.changelogs.base.data.ChangelogEntry +import com.codingcat.changelogs.base.data.ChangelogStorage +import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer +import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.error.YAMLException +import java.io.IOException +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.time.Instant +import java.time.format.DateTimeFormatter +import java.util.* +import kotlin.io.path.exists +import kotlin.io.path.inputStream +import kotlin.io.path.outputStream + +@Suppress("UNCHECKED_CAST") +class YamlChangelogStorage( + private val filePath: Path, +) : ChangelogStorage { + private val lock = Any() + private var cache: MutableList = mutableListOf() + + override val displayName: String = "YAML File" + + override fun init() { + synchronized(lock) { + if (!filePath.exists()) { + this.cache = mutableListOf() + this.saveLocked() + return + } + try { + val config = Yaml() + val data = filePath.inputStream().use { stream -> + config.load>(stream) + } + this.cache = this.deserializeEntries(data ?: emptyMap()) + } catch (e: IOException) { + throw RuntimeException("Failed to load changelog data from ${filePath}", e) + } catch (e: YAMLException) { + throw RuntimeException("Failed to load changelog data from ${filePath}", e) + } + } + } + + override fun shutdown() { + } + + override fun storeEntry(entry: ChangelogEntry) { + synchronized(lock) { + this.cache.removeIf { it.uid == entry.uid } + this.cache.add(entry) + this.saveLocked() + } + } + + override fun updateEntry(entry: ChangelogEntry) { + synchronized(lock) { + val idx = this.cache.indexOfFirst { it.uid == entry.uid } + if (idx >= 0) { + this.cache[idx] = entry + } else { + this.cache.add(entry) + } + this.saveLocked() + } + } + + override fun removeEntry(uid: Int): Boolean { + synchronized(lock) { + val success = this.cache.removeIf { it.uid == uid } + if (success) { + this.saveLocked() + } + return success + } + } + + private fun saveLocked() { + val config = Yaml() + val data = this.cache.map { serializeEntry(it) } + val tempPath = filePath.resolveSibling("${filePath.fileName}.tmp") + try { + tempPath.outputStream().use { os -> + config.dump( + mapOf("entries" to data), + os.writer().buffered(), + ) + } + try { + Files.move( + tempPath, + filePath, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(tempPath, filePath, StandardCopyOption.REPLACE_EXISTING) + } + } catch (e: IOException) { + throw RuntimeException("Failed to save changelog data to ${filePath}", e) + } + } + + private fun deserializeEntries(config: Map): MutableList { + return runCatching { + val entries = config["entries"] as? List> ?: emptyList() + entries.mapIndexedTo(mutableListOf()) { index, entryMap -> + deserializeEntry(index, entryMap) + } + } + .getOrElse { e -> + throw RuntimeException("Failed to deserialize changelog entries", e) + } + } + + private fun deserializeEntry(defaultIndex: Int, data: Map): ChangelogEntry { + val uid = (data["uid"] as? Number)?.toInt() ?: defaultIndex + val linesRaw = data["serializedLines"] as? List ?: emptyList() + val lines = linesRaw.map { GsonComponentSerializer.gson().deserialize(it) } + val recordedAtRaw = data["recordedAt"] as? String + ?: throw IllegalStateException("Missing recordedAt for changelog entry ${uid}") + val recordedAt = DateTimeFormatter.ISO_INSTANT.parse(recordedAtRaw, Instant::from) + val author = (data["author"] as? String)?.let { GsonComponentSerializer.gson().deserialize(it) } + val playersReadRaw = data["playersRead"] as? List ?: emptyList() + val playersRead = playersReadRaw.mapTo(mutableSetOf()) { UUID.fromString(it) } + return ChangelogEntry(uid, lines, recordedAt, author, playersRead) + } + + private fun serializeEntry(entry: ChangelogEntry): Map { + val serializedLines = entry.lines.map { GsonComponentSerializer.gson().serialize(it) } + val recordedAtRaw = DateTimeFormatter.ISO_INSTANT.format(entry.recordedAt) + val serializedAuthor = entry.author?.let { GsonComponentSerializer.gson().serialize(it) } + val rawPlayersRead = entry.playersRead.map { it.toString() } + return mapOf( + "uid" to entry.uid, + "serializedLines" to serializedLines, + "recordedAt" to recordedAtRaw, + "author" to serializedAuthor, + "playersRead" to rawPlayersRead, + ) + } + + override fun listEntries(): List { + synchronized(lock) { + return this.cache.toList() + } + } + + override fun getByUID(uid: Int): ChangelogEntry? { + synchronized(lock) { + return this.cache.find { it.uid == uid } + } + } + + override fun markAsRead(uid: Int, player: UUID) { + synchronized(lock) { + val entry = this.cache.find { it.uid == uid } ?: return + if (entry.playersRead.add(player)) { + this.saveLocked() + } + } + } + + override fun nextUID(): Int { + synchronized(lock) { + return (this.cache.maxOfOrNull { it.uid } ?: -1) + 1 + } + } +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/DialogPackets.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/DialogPackets.kt new file mode 100644 index 0000000..c4ef3d0 --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/DialogPackets.kt @@ -0,0 +1,199 @@ +package com.codingcat.changelogs.base.dialog + +import com.codingcat.changelogs.base.dialog.ui.ChangelogDialog +import com.codingcat.changelogs.base.lang.TranslationSource +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.github.retrooper.packetevents.protocol.dialog.* +import com.github.retrooper.packetevents.protocol.dialog.action.Action +import com.github.retrooper.packetevents.protocol.dialog.body.DialogBody +import com.github.retrooper.packetevents.protocol.dialog.body.PlainMessage +import com.github.retrooper.packetevents.protocol.dialog.body.PlainMessageDialogBody +import com.github.retrooper.packetevents.protocol.dialog.button.ActionButton +import com.github.retrooper.packetevents.protocol.dialog.button.CommonButtonData +import com.github.retrooper.packetevents.protocol.player.User +import com.github.retrooper.packetevents.wrapper.PacketWrapper +import com.github.retrooper.packetevents.wrapper.configuration.server.WrapperConfigServerClearDialog +import com.github.retrooper.packetevents.wrapper.configuration.server.WrapperConfigServerShowDialog +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerClearDialog +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerShowDialog +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.permission.PermissionChecker +import net.kyori.adventure.text.Component +import net.kyori.adventure.util.TriState +import java.util.* + +private const val CONFIRM_BUTTON_WIDTH: Int = 100 +private const val CANCEL_BUTTON_WIDTH: Int = 100 +private const val CLOSE_BUTTON_WIDTH: Int = 40 +private const val CONFIRM_DIALOG_COLUMNS: Int = 2 + +object DialogPackets { + fun showDialog(player: PlatformPlayer, dialog: Dialog, packetPhase: PacketPhase) { + val wrapper = if (packetPhase == PacketPhase.CONFIGURATION) { + WrapperConfigServerShowDialog(dialog) + } else { + WrapperPlayServerShowDialog(dialog) + } + packet(player, wrapper) + } + + fun showSimpleConfirm( + player: PlatformPlayer, + titleTranslation: String, + bodyTranslation: String, + confirm: Action, + confirmDanger: Boolean, + cancel: Action, + packetPhase: PacketPhase, + ) { + val common: CommonDialogData = createSimpleDialog( + player, titleTranslation, bodyTranslation, + closeAfter = false, + canEscape = false, + ) + val confirmSuffix = if (confirmDanger) "_danger" else "" + val confirmButton = ActionButton( + CommonButtonData( + TranslationSource.translatableManual( + player, + "dialog.popup.confirm${confirmSuffix}", + ), + null, + CONFIRM_BUTTON_WIDTH, + ), + confirm, + ) + val cancelButton = ActionButton( + CommonButtonData( + TranslationSource.translatableManual(player, "dialog.popup.cancel"), + null, + CANCEL_BUTTON_WIDTH, + ), + cancel, + ) + showDialog( + player, + MultiActionDialog(common, listOf(confirmButton, cancelButton), null, CONFIRM_DIALOG_COLUMNS), + packetPhase, + ) + } + + @JvmOverloads + fun showSimpleNotice( + player: PlatformPlayer, + titleTranslation: String, + bodyTranslation: String, + action: Action? = null, + packetPhase: PacketPhase = PacketPhase.PLAY, + ) { + val common: CommonDialogData = + createSimpleDialog(player, titleTranslation, bodyTranslation, action == null, true) + val closeButton = ActionButton( + CommonButtonData( + TranslationSource.translatableManual(player, "dialog.popup.close"), + null, + CLOSE_BUTTON_WIDTH, + ), + action, + ) + showDialog(player, NoticeDialog(common, closeButton), packetPhase) + } + + fun createSimpleDialog( + player: PlatformPlayer, + titleTranslation: String, + bodyTranslation: String, + closeAfter: Boolean, + canEscape: Boolean, + ): CommonDialogData { + val body: DialogBody = PlainMessageDialogBody( + PlainMessage( + TranslationSource.translatableManual(player, bodyTranslation), + ChangelogDialog.LINE_WIDTH, + ), + ) + return CommonDialogData( + TranslationSource.translatableManual(player, titleTranslation), + null, + canEscape, + false, + if (closeAfter) DialogAction.CLOSE else DialogAction.NONE, + listOf(body), + mutableListOf(), + ) + } + + fun clearDialog(player: PlatformPlayer, packetPhase: PacketPhase) { + val wrapper = if (packetPhase == PacketPhase.CONFIGURATION) { + WrapperConfigServerClearDialog() + } else { + WrapperPlayServerClearDialog() + } + packet(player, wrapper) + } + + fun wrapPacketEventsUser(user: User): PlatformPlayer { + return PacketEventsUserWrappedPlayer(user) + } + + private fun packet(player: PlatformPlayer, wrapper: PacketWrapper<*>) { + val user = player.asPacketEventsUser() as User + user.sendPacket(wrapper) + } + + enum class PacketPhase { + CONFIGURATION, + PLAY, + } + + private class PacketEventsUserWrappedPlayer( + private val user: User, + ) : PlatformPlayer { + override val uniqueId: UUID + get() = this.user.uuid + + override val name: String + get() = this.user.name ?: "Unknown" + + override val clientLocale: Locale + get() = Locale.US + + override val isFirstJoin: Boolean + get() = false + + override val isNativeAdmin: TriState + get() = TriState.NOT_SET + + override fun kick(reason: Component?) { + this.user.closeConnection() + } + + override fun asPacketEventsUser(): Any { + return this.user + } + + override fun asAudience(): Audience { + return Audience.empty() + } + + override fun asPermissionChecker(): PermissionChecker { + return PermissionChecker.always(TriState.NOT_SET) + } + + companion object { + private val EXCEPTION: RuntimeException = + UnsupportedOperationException("Not supported on PacketEvents user wrapper") + } + } +} + +fun PlatformPlayer.showDialog( + dialog: Dialog, + packetPhase: DialogPackets.PacketPhase = DialogPackets.PacketPhase.PLAY, +) { + DialogPackets.showDialog(this, dialog, packetPhase) +} + +fun PlatformPlayer.clearDialog(packetPhase: DialogPackets.PacketPhase = DialogPackets.PacketPhase.PLAY) { + DialogPackets.clearDialog(this, packetPhase) +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/DialogSessionManager.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/DialogSessionManager.kt new file mode 100644 index 0000000..3650acd --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/DialogSessionManager.kt @@ -0,0 +1,211 @@ +package com.codingcat.changelogs.base.dialog + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.github.retrooper.packetevents.event.PacketListener +import com.github.retrooper.packetevents.event.PacketListenerCommon +import com.github.retrooper.packetevents.event.PacketListenerPriority +import com.github.retrooper.packetevents.event.PacketReceiveEvent +import com.github.retrooper.packetevents.protocol.chat.clickevent.CustomClickEvent +import com.github.retrooper.packetevents.protocol.dialog.action.Action +import com.github.retrooper.packetevents.protocol.dialog.action.DynamicCustomAction +import com.github.retrooper.packetevents.protocol.dialog.action.StaticAction +import com.github.retrooper.packetevents.protocol.nbt.NBTCompound +import com.github.retrooper.packetevents.protocol.packettype.PacketType +import com.github.retrooper.packetevents.protocol.util.NbtCodecException +import com.github.retrooper.packetevents.resources.ResourceLocation +import com.github.retrooper.packetevents.util.adventure.NbtTagHolder +import com.github.retrooper.packetevents.wrapper.common.client.WrapperCommonClientCustomClickAction +import com.github.retrooper.packetevents.wrapper.configuration.client.WrapperConfigClientCustomClickAction +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientCustomClickAction +import net.kyori.adventure.key.Key +import net.kyori.adventure.nbt.api.BinaryTagHolder +import net.kyori.adventure.text.event.ClickEvent +import java.util.* +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +/** + * PacketEvents listener coordinating dialog click actions, session state, and player freeze futures. + */ +object DialogSessionManager : PacketListener { + private val staticActions: MutableSet = ConcurrentHashMap.newKeySet() + private val activeSessions: MutableMap = ConcurrentHashMap() + private val sessionData: MutableMap = ConcurrentHashMap() + private val frozenViewers: MutableMap> = ConcurrentHashMap() + private var selfListener: PacketListenerCommon? = null + + fun handleCustomClick(packetWrapper: WrapperCommonClientCustomClickAction<*>, player: PlatformPlayer) { + val key: Key = packetWrapper.id.key() + val fSIdx: Int = key.value().indexOf('/') + val lSIdx: Int = key.value().lastIndexOf('/') + if (key.namespace() != ServerChangelogs.NAMESPACE || + fSIdx == -1 || + !key.value().startsWith("dialog/") || + (fSIdx + 1) >= lSIdx || + lSIdx == (key.value().length - 1) + ) { + return + } + val dialogId = key.value().substring(fSIdx + 1, lSIdx) + val actionId = key.value().substring(lSIdx + 1) + if (dialogId != activeSessions[player.uniqueId] && !staticActions.contains(key)) return + val dialog: PluginDialog = runCatching { + ServerChangelogs.dialogHolder.getFromId(dialogId) + }.getOrNull() ?: return + + runCatching { + dialog.onActionTriggered(actionId, packetWrapper.payload as? NBTCompound, player, this) + }.onFailure { e -> + if (e !is NbtCodecException) { + throw RuntimeException( + "Failed to run dialog action handler for \"${dialogId}\", action \"${actionId}\"", + e, + ) + } + } + } + + override fun onPacketReceive(event: PacketReceiveEvent) { + if (event.packetType === PacketType.Play.Client.CUSTOM_CLICK_ACTION) { + handleCustomClick( + WrapperPlayClientCustomClickAction(event), + playerFromPacket(event), + ) + } + if (event.packetType === PacketType.Configuration.Client.CUSTOM_CLICK_ACTION) { + handleCustomClick( + WrapperConfigClientCustomClickAction(event), + playerFromPacket(event), + ) + } + } + + private fun playerFromPacket(event: PacketReceiveEvent): PlatformPlayer { + return runCatching { + ServerChangelogs.platform.playerManager.fromNative(event.getPlayer()) + }.getOrElse { + // If obtaining the native PlatformPlayer implementation fails (e.g. during the configuration phase, + // where event.getPlayer() can return null), fall back to a wrapped PE User implementation. + DialogPackets.wrapPacketEventsUser(event.user) + } + } + + fun registerEvents() { + this.selfListener = + ServerChangelogs.packetEventsApi.eventManager.registerListener(this, PacketListenerPriority.NORMAL) + } + + fun unregisterEvents() { + ServerChangelogs.packetEventsApi.eventManager.unregisterListener(this.selfListener) + this.selfListener = null + } + + fun createStaticAction(dialog: PluginDialog, id: String): Action { + val key = this.createStaticKey(dialog, id) + return StaticAction(CustomClickEvent(ResourceLocation(key), null)) + } + + fun createStaticClickEvent(dialog: PluginDialog, id: String): ClickEvent<*> { + return ClickEvent.custom(this.createStaticKey(dialog, id), NONE_TAG_HOLDER) + } + + fun createSessionBasedAction(dialog: PluginDialog, id: String, includeInputs: Boolean): Action { + return this.createSessionBasedAction(dialog, id, null, includeInputs) + } + + fun createSessionBasedAction( + dialog: PluginDialog, + id: String, + data: NBTCompound?, + includeInputs: Boolean, + ): Action { + val key = this.createSessionBasedKey(dialog, id) + val clickEvent = CustomClickEvent(ResourceLocation(key), data) + return if (includeInputs) { + DynamicCustomAction(clickEvent.id, data) + } else { + StaticAction(clickEvent) + } + } + + fun createSessionBasedClickEvent(dialog: PluginDialog, id: String): ClickEvent<*> { + return ClickEvent.custom(this.createSessionBasedKey(dialog, id), null) + } + + fun createSessionBasedClickEvent(dialog: PluginDialog, id: String, data: NBTCompound?): ClickEvent<*> { + return ClickEvent.custom( + this.createSessionBasedKey(dialog, id), + data?.let(::NbtTagHolder) ?: NONE_TAG_HOLDER, + ) + } + + private fun createSessionBasedKey(dialog: PluginDialog, id: String): Key { + return ServerChangelogs.key("dialog/${dialog.id}/${id}") + } + + private fun createStaticKey(dialog: PluginDialog, id: String): Key { + val key = this.createSessionBasedKey(dialog, id) + this.staticActions.add(key) + return key + } + + fun startSessionIfNoneActive( + dialog: PluginDialog, + player: PlatformPlayer, + initialDataSupplier: () -> Any, + ) { + if (this.isSessionActive(player, dialog)) return + this.startSession(dialog, player, initialDataSupplier()) + } + + fun startSession(dialog: PluginDialog, player: PlatformPlayer, initialData: Any) { + this.setSessionData(player, initialData) + this.activeSessions[player.uniqueId] = dialog.id + } + + fun endSession(player: PlatformPlayer) { + this.activeSessions.remove(player.uniqueId) + this.sessionData.remove(player.uniqueId) + } + + fun isSessionActive(player: PlatformPlayer, dialog: PluginDialog): Boolean { + return dialog.id == this.activeSessions[player.uniqueId] + } + + fun setSessionData(player: PlatformPlayer, data: Any) { + this.sessionData[player.uniqueId] = data + } + + @Throws(ClassCastException::class) + fun getSessionData(player: PlatformPlayer, dataType: Class): T { + return dataType.cast(this.sessionData[player.uniqueId]) + } + + fun isFrozen(player: PlatformPlayer): Boolean { + val future = this.frozenViewers[player.uniqueId] + return future != null && !future.isDone + } + + fun unfreeze(player: PlatformPlayer) { + val future = this.frozenViewers.remove(player.uniqueId) + future?.complete(null) + } + + fun freeze(player: PlatformPlayer) { + this.frozenViewers.computeIfAbsent(player.uniqueId) { CompletableFuture() } + } + + fun waitForUnfreeze(player: PlatformPlayer, timeoutSeconds: Long = 45) { + val future = this.frozenViewers[player.uniqueId] ?: return + runCatching { + future.get(timeoutSeconds, TimeUnit.SECONDS) + }.onFailure { + // Future timed out, was cancelled, or completed exceptionally + } + this.frozenViewers.remove(player.uniqueId) + } + + val NONE_TAG_HOLDER: BinaryTagHolder = BinaryTagHolder.binaryTagHolder("") +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/PluginDialog.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/PluginDialog.kt new file mode 100644 index 0000000..7c1cce6 --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/PluginDialog.kt @@ -0,0 +1,106 @@ +package com.codingcat.changelogs.base.dialog + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.base.dialog.ui.ChangelogDialog +import com.codingcat.changelogs.base.dialog.ui.editor.ChangelogEditorDialog +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.github.retrooper.packetevents.protocol.dialog.Dialog +import com.github.retrooper.packetevents.protocol.nbt.NBTCompound + +/** + * Contract representing an interactive dialog screen constructed and displayed to a [PlatformPlayer]. + */ +interface PluginDialog { + /** + * Unique identifier string for this dialog type. + */ + val id: String + + /** + * Constructs a PacketEvents [Dialog] tailored for [player] within the given [sessionManager]. + */ + fun build(player: PlatformPlayer, sessionManager: DialogSessionManager): Dialog + + /** + * Associates [session] data with [player] and displays the dialog during [packetPhase]. + */ + fun showTo( + player: PlatformPlayer, + sessionManager: DialogSessionManager, + session: Any, + packetPhase: DialogPackets.PacketPhase, + ) { + sessionManager.startSession(this, player, session) + this.showTo(player, sessionManager, packetPhase) + } + + /** + * Renders and displays the dialog to [player] during [packetPhase]. + */ + fun showTo(player: PlatformPlayer, sessionManager: DialogSessionManager, packetPhase: DialogPackets.PacketPhase) { + val dialog = this.build(player, sessionManager) + player.showDialog(dialog, packetPhase) + } + + /** + * Callback invoked when the user interacts with an element triggering [action] with optional NBT [data]. + */ + fun onActionTriggered( + action: String, + data: NBTCompound?, + source: PlatformPlayer, + sessionManager: DialogSessionManager, + ) + + /** + * Pre-check hook allowing active sessions to reject dialog destruction or plugin reloading. + * + * @throws DestroyRejectedException if active editing state prevents safe reload. + */ + @Throws(DestroyRejectedException::class) + fun attemptDestroy() { + } + + object Holder { + private val dialogMap: MutableMap = mutableMapOf() + + fun recreate() { + this.dialogMap.clear() + val editorDialog = ChangelogEditorDialog( + ServerChangelogs.changelogStorage, + ServerChangelogs.pluginConfig().useNativeFallbackPermissions(), + ) + val changelogDialog = ChangelogDialog( + ServerChangelogs.changelogStorage, + this, + ServerChangelogs.pluginConfig().dateFormatter, + ServerChangelogs.pluginConfig().showChangelogHeader(), + ServerChangelogs.pluginConfig().createChangelogHeaderStack(), + ServerChangelogs.pluginConfig().useNativeFallbackPermissions(), + ) + this.dialogMap[editorDialog.id] = editorDialog + this.dialogMap[changelogDialog.id] = changelogDialog + } + + @Throws(DestroyRejectedException::class) + fun ensureCanReload() { + this.dialogMap.values.forEach { it.attemptDestroy() } + } + + fun getFromId(id: String): PluginDialog { + return this.dialogMap[id] ?: throw NullPointerException("No dialog found matching ID \"${id}\"") + } + + @Suppress("UNCHECKED_CAST") + fun getFromType(cls: Class): T { + val found = this.dialogMap.values.find { cls.isAssignableFrom(it.javaClass) } + ?: throw IllegalArgumentException("No matching dialog found") + return found as T + } + + inline fun getFromType(): T = + getFromType(T::class.java) + } + + class DestroyRejectedException(val key: String) : Exception() +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.kt new file mode 100644 index 0000000..cc0d87e --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.kt @@ -0,0 +1,247 @@ +package com.codingcat.changelogs.base.dialog.ui + +import com.codingcat.changelogs.base.compat.PacketEventsFix +import com.codingcat.changelogs.base.data.ChangelogEntry +import com.codingcat.changelogs.base.data.ChangelogStorage +import com.codingcat.changelogs.base.dialog.DialogPackets +import com.codingcat.changelogs.base.dialog.DialogSessionManager +import com.codingcat.changelogs.base.dialog.PluginDialog +import com.codingcat.changelogs.base.dialog.ui.editor.ChangelogEditorDialog +import com.codingcat.changelogs.base.dialog.ui.editor.EditorSession +import com.codingcat.changelogs.base.lang.TranslationSource +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.github.retrooper.packetevents.protocol.dialog.CommonDialogData +import com.github.retrooper.packetevents.protocol.dialog.Dialog +import com.github.retrooper.packetevents.protocol.dialog.DialogAction +import com.github.retrooper.packetevents.protocol.dialog.NoticeDialog +import com.github.retrooper.packetevents.protocol.dialog.body.DialogBody +import com.github.retrooper.packetevents.protocol.dialog.body.ItemDialogBody +import com.github.retrooper.packetevents.protocol.dialog.body.PlainMessage +import com.github.retrooper.packetevents.protocol.dialog.body.PlainMessageDialogBody +import com.github.retrooper.packetevents.protocol.dialog.button.ActionButton +import com.github.retrooper.packetevents.protocol.dialog.button.CommonButtonData +import com.github.retrooper.packetevents.protocol.item.ItemStack +import com.github.retrooper.packetevents.protocol.nbt.NBTCompound +import com.github.retrooper.packetevents.protocol.nbt.NBTInt +import net.kyori.adventure.text.Component +import java.time.format.DateTimeFormatter + +private const val EMPTY_DIALOG_WIDTH: Int = 350 +private const val HEADER_MESSAGE_WIDTH: Int = 260 +private const val HEADER_ITEM_WIDTH: Int = 17 +private const val HEADER_ITEM_HEIGHT: Int = 17 +private const val CLOSE_BUTTON_WIDTH: Int = 60 + +class ChangelogDialog( + private val storage: ChangelogStorage, + private val holder: PluginDialog.Holder, + private val dateFormatter: DateTimeFormatter, + private val addHeader: Boolean, + private val headerItem: ItemStack?, + private val useFallbackPermissions: Boolean, +) : PluginDialog { + override val id: String = "changelog_view" + + override fun build( + player: PlatformPlayer, + sessionManager: DialogSessionManager, + ): Dialog { + val canManage = this.canManage(player, sessionManager) + if (canManage) sessionManager.startSessionIfNoneActive(this, player) { Any() } + val bodyList = mutableListOf() + + if (this.addHeader) { + val headerMessage = PlainMessage( + TranslationSource.translatableManual(player, "dialog.changelog.header"), + HEADER_MESSAGE_WIDTH, + ) + val body = headerItem?.let { item -> + ItemDialogBody( + item, + headerMessage, + false, + false, + HEADER_ITEM_WIDTH, + HEADER_ITEM_HEIGHT, + ).also(PacketEventsFix::fixItemBody) + } ?: PlainMessageDialogBody(headerMessage) + bodyList.add(body) + } + + val entries = this.storage.listEntries().asReversed() + if (entries.isEmpty()) { + bodyList.add( + PlainMessageDialogBody( + PlainMessage( + TranslationSource.translatableManual(player, "dialog.changelog.empty"), + EMPTY_DIALOG_WIDTH, + ), + ), + ) + } else { + entries.forEach { entry -> + val formatted = formatEntry(entry, player, sessionManager, canManage) + bodyList.add( + PlainMessageDialogBody( + PlainMessage(formatted, LINE_WIDTH), + ), + ) + } + } + + val common = CommonDialogData( + TranslationSource.translatableManual(player, "dialog.changelog.title"), + null, + true, + false, + if (canManage) DialogAction.NONE else DialogAction.CLOSE, + bodyList, + mutableListOf(), + ) + val button = ActionButton( + CommonButtonData( + TranslationSource.translatableManual(player, "dialog.changelog.button.close"), + null, + CLOSE_BUTTON_WIDTH, + ), + sessionManager.createStaticAction(this, "confirm_read"), + ) + return NoticeDialog(common, button) + } + + private fun formatEntry( + entry: ChangelogEntry, + player: PlatformPlayer, + sessionManager: DialogSessionManager, + canManage: Boolean, + ): Component { + val linesComponent: Component = createLinesComponent(player, null, entry.lines) + val unreadSuffix = if (!entry.hasRead(player)) "_unread" else "" + var entryComponent: Component = TranslationSource.translatableManual( + player, + "dialog.changelog.entry${unreadSuffix}", + Component.text(dateFormatter.format(entry.recordedAt)), + linesComponent, + entry.author ?: TranslationSource.translatableManual(player, "dialog.changelog.unspecified_author"), + ) + if (canManage) { + val prefix = "dialog.changelog.manage.changelog_action." + val payload = NBTCompound() + payload.setTag("uid", NBTInt(entry.uid)) + val edit: Component = TranslationSource.translatableManual( + player, + "${prefix}format", + TranslationSource.translatable("${prefix}edit"), + ).clickEvent(sessionManager.createSessionBasedClickEvent(this, "start_editing", payload)) + + val delete: Component = TranslationSource.translatableManual( + player, + "${prefix}format", + TranslationSource.translatable("${prefix}delete"), + ).clickEvent(sessionManager.createSessionBasedClickEvent(this, "request_delete", payload)) + + entryComponent = entryComponent.appendSpace().append(edit).appendSpace().append(delete) + } + return entryComponent + } + + override fun onActionTriggered( + action: String, + data: NBTCompound?, + source: PlatformPlayer, + sessionManager: DialogSessionManager, + ) { + try { + val canManage = this.canManage(source, sessionManager) + when (action) { + "confirm_read" -> { + val unreadEntries = this.storage.listEntries().filter { !it.hasRead(source) } + unreadEntries.forEach { entry -> + storage.markAsRead(entry.uid, source.uniqueId) + } + if (canManage) { + sessionManager.endSession(source) + DialogPackets.clearDialog(source, DialogPackets.PacketPhase.PLAY) + } + if (unreadEntries.isNotEmpty()) { + source.asAudience().sendMessage( + TranslationSource.translatable( + "dialog.changelog.read", + Component.text(unreadEntries.size), + ), + ) + } + } + + "reopen" -> { + if (canManage) this.showTo(source, sessionManager, DialogPackets.PacketPhase.PLAY) + } + + "start_editing" -> { + if (data == null || !canManage) return + val uid = data.getNumberTagValueOrThrow("uid").toInt() + val entry: ChangelogEntry = this.storage.getByUID(uid) ?: return + sessionManager.endSession(source) + val session = EditorSession.Edit(entry) + this.holder.getFromType() + .showTo(source, sessionManager, session, DialogPackets.PacketPhase.PLAY) + } + + "request_delete" -> { + if (data == null || !canManage) return + val uid = data.getNumberTagValueOrThrow("uid").toInt() + val payload = NBTCompound() + payload.setTag("uid", NBTInt(uid)) + val prefix = "dialog.changelog.manage.confirm_delete." + DialogPackets.showSimpleConfirm( + source, + "${prefix}title", + "${prefix}content", + sessionManager.createSessionBasedAction(this, "confirm_delete", payload, false), + true, + sessionManager.createSessionBasedAction(this, "reopen", false), + DialogPackets.PacketPhase.PLAY, + ) + } + + "confirm_delete" -> { + if (data == null || !canManage) return + val uid = data.getNumberTagValueOrThrow("uid").toInt() + this.storage.removeEntry(uid) + this.showTo(source, sessionManager, DialogPackets.PacketPhase.PLAY) + } + } + } finally { + // Ensure the player is able to continue gameplay even if any exceptions occur + if (action == "confirm_read") sessionManager.unfreeze(source) + } + } + + private fun canManage(player: PlatformPlayer, sessionManager: DialogSessionManager): Boolean { + return ChangelogEditorDialog.permissionCheck( + "manage", + player, + useFallbackPermissions, + ) && !sessionManager.isFrozen(player) + } + + companion object { + const val LINE_WIDTH: Int = 440 + + fun createLinesComponent( + player: PlatformPlayer, + mapper: ((Component, Int) -> Component)?, + lines: List, + ): Component { + val lineMapper = mapper ?: { c, _ -> c } + var component: Component = Component.empty() + lines.forEachIndexed { i, rawLine -> + var line = TranslationSource.translatableManual(player, "dialog.changelog.entry_line", rawLine) + line = lineMapper(line, i) + component = component.append(line) + if (i < lines.size - 1) component = component.appendNewline() + } + return component + } + } +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.kt new file mode 100644 index 0000000..250af42 --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.kt @@ -0,0 +1,421 @@ +package com.codingcat.changelogs.base.dialog.ui.editor + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.base.compat.PacketEventsFix +import com.codingcat.changelogs.base.data.ChangelogStorage +import com.codingcat.changelogs.base.dialog.DialogPackets +import com.codingcat.changelogs.base.dialog.DialogSessionManager +import com.codingcat.changelogs.base.dialog.PluginDialog +import com.codingcat.changelogs.base.dialog.ui.ChangelogDialog +import com.codingcat.changelogs.base.lang.TranslationSource +import com.codingcat.changelogs.base.lang.TranslationSource.Companion.translatableManual +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.github.retrooper.packetevents.protocol.dialog.CommonDialogData +import com.github.retrooper.packetevents.protocol.dialog.Dialog +import com.github.retrooper.packetevents.protocol.dialog.DialogAction +import com.github.retrooper.packetevents.protocol.dialog.MultiActionDialog +import com.github.retrooper.packetevents.protocol.dialog.body.ItemDialogBody +import com.github.retrooper.packetevents.protocol.dialog.body.PlainMessage +import com.github.retrooper.packetevents.protocol.dialog.body.PlainMessageDialogBody +import com.github.retrooper.packetevents.protocol.dialog.button.ActionButton +import com.github.retrooper.packetevents.protocol.dialog.button.CommonButtonData +import com.github.retrooper.packetevents.protocol.dialog.input.Input +import com.github.retrooper.packetevents.protocol.dialog.input.TextInputControl +import com.github.retrooper.packetevents.protocol.item.ItemStack +import com.github.retrooper.packetevents.protocol.item.type.ItemTypes +import com.github.retrooper.packetevents.protocol.nbt.NBTByte +import com.github.retrooper.packetevents.protocol.nbt.NBTCompound +import com.github.retrooper.packetevents.protocol.nbt.NBTInt +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.Component.text +import net.kyori.adventure.text.format.TextDecoration +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +private const val ITEM_BODY_WIDTH: Int = 15 +private const val ITEM_BODY_HEIGHT: Int = 15 +private const val SUBTITLE_WIDTH: Int = 160 +private const val RESTORED_SESSION_WIDTH: Int = 400 +private const val LINE_INPUT_WIDTH: Int = 350 +private const val LINE_INPUT_MAX_LENGTH: Int = 5000 +private const val AUTHOR_INPUT_WIDTH: Int = 200 +private const val AUTHOR_INPUT_MAX_LENGTH: Int = 200 +private const val COMMIT_BUTTON_WIDTH: Int = 160 +private const val LINE_ACTION_BUTTON_WIDTH: Int = 100 +private const val CLOSE_BUTTON_WIDTH: Int = 100 +private const val SAVE_SESSION_BUTTON_WIDTH: Int = 140 +private const val DISCARD_SESSION_BUTTON_WIDTH: Int = 140 +private const val CANCEL_BUTTON_WIDTH: Int = 100 +private const val EDITOR_DIALOG_COLUMNS: Int = 3 +private const val CONFIRM_CLOSE_COLUMNS: Int = 3 + +class ChangelogEditorDialog( + private val storage: ChangelogStorage, + private val useFallbackPermissions: Boolean, +) : PluginDialog { + private val savedSessions: MutableMap = ConcurrentHashMap() + + override val id: String = "changelog_editor" + + override fun build( + player: PlatformPlayer, + sessionManager: DialogSessionManager, + ): Dialog { + sessionManager.startSessionIfNoneActive(this, player) { + this.savedSessions.remove(player.uniqueId)?.takeIf { it.canBeSaved() } + ?: EditorSession.Create(storage.nextUID()) + } + val session: EditorSession = sessionManager.getSessionData(player, EditorSession::class.java) + val sessionTranslation = "dialog.editor.${session.id}" + val previewLines = session.deserializeLines() + + val lineMapper: (Component, Int) -> Component = { rawLine, idx -> + var line = rawLine + val isEditing = session.editingLineIndex == idx + if (isEditing) line = line.decoration(TextDecoration.BOLD, true) + val prefix = "dialog.editor.line_action." + val payload = NBTCompound() + payload.setTag("target_line", NBTInt(idx)) + var edit: Component? = null + if (!isEditing) { + edit = translatableManual( + player, + "${prefix}format", + TranslationSource.translatable("${prefix}edit"), + ) + .decoration(TextDecoration.BOLD, false) + .clickEvent(sessionManager.createSessionBasedClickEvent(this, "start_edit_line", payload)) + } + val remove: Component = translatableManual( + player, + "${prefix}format", + TranslationSource.translatable("${prefix}remove"), + ) + .decoration(TextDecoration.BOLD, false) + .clickEvent(sessionManager.createSessionBasedClickEvent(this, "remove_line", payload)) + var finalCmp = line.appendSpace() + if (!isEditing) edit?.let { finalCmp = finalCmp.append(it).appendSpace() } + finalCmp.append(remove) + } + + val itemBody = ItemDialogBody( + ItemStack.builder().type(ItemTypes.WRITABLE_BOOK).build(), + PlainMessage( + translatableManual(player, "${sessionTranslation}.subtitle", text(session.entryUID + 1)), + SUBTITLE_WIDTH, + ), + false, + false, + ITEM_BODY_WIDTH, + ITEM_BODY_HEIGHT, + ) + PacketEventsFix.fixItemBody(itemBody) + + val bodyList = mutableListOf( + itemBody, + PlainMessageDialogBody( + PlainMessage( + translatableManual(player, "dialog.editor.hint"), + ChangelogDialog.LINE_WIDTH, + ), + ), + ) + + if (session.showRestoredMessage) { + bodyList.add( + PlainMessageDialogBody( + PlainMessage( + translatableManual(player, "dialog.editor.restored_session"), + RESTORED_SESSION_WIDTH, + ), + ), + ) + } + + bodyList.add( + PlainMessageDialogBody( + PlainMessage( + if (previewLines.isNotEmpty()) { + ChangelogDialog.createLinesComponent(player, lineMapper, previewLines) + } else { + translatableManual(player, "dialog.editor.empty_preview") + }, + ChangelogDialog.LINE_WIDTH, + ), + ), + ) + + val inputs = listOf( + Input( + "line", + TextInputControl( + LINE_INPUT_WIDTH, + translatableManual(player, "dialog.editor.input.contents"), + true, + session.currentLine, + LINE_INPUT_MAX_LENGTH, + null, + ), + ), + Input( + "author", + TextInputControl( + AUTHOR_INPUT_WIDTH, + translatableManual(player, "dialog.editor.input.author"), + true, + session.author, + AUTHOR_INPUT_MAX_LENGTH, + null, + ), + ), + ) + val common = CommonDialogData( + translatableManual(player, titleKey(session)), + null, + true, + false, + DialogAction.NONE, + bodyList, + inputs, + ) + val buttons = mutableListOf() + buttons.add( + ActionButton( + CommonButtonData( + translatableManual(player, "${sessionTranslation}.commit_button"), + null, + COMMIT_BUTTON_WIDTH, + ), + sessionManager.createSessionBasedAction(this, "commit", true), + ), + ) + val lineAction = if (session.editingLineIndex != -1) "edit_line" else "add_line" + buttons.add( + ActionButton( + CommonButtonData( + translatableManual(player, "dialog.editor.button.${lineAction}"), + null, + LINE_ACTION_BUTTON_WIDTH, + ), + sessionManager.createSessionBasedAction(this, lineAction, true), + ), + ) + val closeBtn = ActionButton( + CommonButtonData( + translatableManual(player, "dialog.editor.button.close"), + null, + CLOSE_BUTTON_WIDTH, + ), + sessionManager.createSessionBasedAction(this, "try_close", true), + ) + return MultiActionDialog(common, buttons, closeBtn, EDITOR_DIALOG_COLUMNS) + } + + private fun buildConfirmCloseDialog( + p: PlatformPlayer, + sessionManager: DialogSessionManager, + ): Dialog { + val session: EditorSession = sessionManager.getSessionData(p, EditorSession::class.java) + val prefix = "dialog.editor.confirm_close." + val common: CommonDialogData = DialogPackets.createSimpleDialog( + p, + titleKey(session), + "${prefix}content", + closeAfter = false, + canEscape = false, + ) + + fun closeAction(save: Boolean): com.github.retrooper.packetevents.protocol.dialog.action.Action { + val payload = NBTCompound() + payload.setTag("save_session", NBTByte(save)) + return sessionManager.createSessionBasedAction(this, "close", payload, false) + } + + val saveSession = ActionButton( + CommonButtonData( + translatableManual(p, "${prefix}button.save_session"), + null, + SAVE_SESSION_BUTTON_WIDTH, + ), + closeAction(true), + ) + val discardSession = ActionButton( + CommonButtonData( + translatableManual(p, "${prefix}button.discard_session"), + null, + DISCARD_SESSION_BUTTON_WIDTH, + ), + closeAction(false), + ) + val cancel = ActionButton( + CommonButtonData( + translatableManual(p, "${prefix}button.cancel"), + null, + CANCEL_BUTTON_WIDTH, + ), + sessionManager.createSessionBasedAction(this, "reopen", false), + ) + return MultiActionDialog( + common, + listOf(saveSession, discardSession, cancel), + null, + CONFIRM_CLOSE_COLUMNS, + ) + } + + private fun actuallyClose(source: PlatformPlayer, sessionManager: DialogSessionManager) { + sessionManager.endSession(source) + DialogPackets.clearDialog(source, DialogPackets.PacketPhase.PLAY) + } + + override fun onActionTriggered( + action: String, + data: NBTCompound?, + source: PlatformPlayer, + sessionManager: DialogSessionManager, + ) { + val session: EditorSession = sessionManager.getSessionData(source, EditorSession::class.java) + session.showRestoredMessage = false + if (data != null && data.contains("line")) { + val rawLine: String = data.getStringTagValueOrThrow("line") + val rawAuthor: String = data.getStringTagValueOrThrow("author") + session.currentLine = rawLine + session.author = rawAuthor + } + when (action) { + "try_close" -> { + if (session.canBeSaved()) { + val dialog = this.buildConfirmCloseDialog(source, sessionManager) + DialogPackets.showDialog(source, dialog, DialogPackets.PacketPhase.PLAY) + return + } + this.actuallyClose(source, sessionManager) + } + + "close" -> { + if (data == null) return + val saveSession: Boolean = data.getBooleanOrThrow("save_session") + if (saveSession) { + session.showRestoredMessage = true + this.savedSessions[source.uniqueId] = session + } + this.actuallyClose(source, sessionManager) + } + + "reopen" -> this.showTo(source, sessionManager, DialogPackets.PacketPhase.PLAY) + + "add_line", "edit_line" -> { + if (action == "edit_line" && session.editingLineIndex == -1) return + var removed = false + if (session.currentLine.isBlank()) { + if (action == "add_line") { + this.showRetry(source, "add_empty_line", session, sessionManager) + return + } else { + session.rawLines.removeAt(session.editingLineIndex) + removed = true + } + } + if (action == "edit_line") { + if (!removed) session.rawLines[session.editingLineIndex] = session.currentLine + session.editingLineIndex = -1 + } else { + session.rawLines.add(session.currentLine) + } + session.currentLine = "" + this.showTo(source, sessionManager, DialogPackets.PacketPhase.PLAY) + } + + "start_edit_line", "remove_line" -> { + if (data == null) return + val lineIdx = data.getNumberTagValueOrThrow("target_line").toInt() + if (lineIdx < 0 || lineIdx >= session.rawLines.size) return + if (action == "start_edit_line") { + val line = session.rawLines[lineIdx] + session.currentLine = line + session.editingLineIndex = lineIdx + } else { + if (session.editingLineIndex == lineIdx) session.editingLineIndex = -1 + session.rawLines.removeAt(lineIdx) + session.currentLine = "" + } + this.showTo(source, sessionManager, DialogPackets.PacketPhase.PLAY) + } + + "commit" -> { + if (!permissionCheck( + session.permission, + source, + useFallbackPermissions, + ) + ) { + sessionManager.endSession(source) + DialogPackets.showSimpleNotice( + source, + titleKey(session), + "dialog.editor.error.no_permission", + ) + return + } + if (data == null) return + if (session.rawLines.isEmpty()) { + this.showRetry(source, "no_lines", session, sessionManager) + return + } + try { + session.commit(this.storage) + } catch (e: EditorSession.CommitException) { + this.showRetry( + source, + "${session.id}.${e.translationKeyPart}", + session, + sessionManager, + ) + return + } + sessionManager.endSession(source) + DialogPackets.showSimpleNotice( + source, + titleKey(session), + "dialog.editor.${session.id}.success", + ) + } + } + } + + @Throws(PluginDialog.DestroyRejectedException::class) + override fun attemptDestroy() { + if (this.savedSessions.isNotEmpty()) throw PluginDialog.DestroyRejectedException("saved_sessions") + } + + private fun showRetry( + source: PlatformPlayer, + errorPart: String, + session: EditorSession, + sessionManager: DialogSessionManager, + ) { + DialogPackets.showSimpleNotice( + source, + titleKey(session), + "dialog.editor.error.${errorPart}", + sessionManager.createSessionBasedAction(this, "reopen", false), + DialogPackets.PacketPhase.PLAY, + ) + } + + companion object { + fun titleKey(session: EditorSession): String = "dialog.editor.${session.id}.title" + + fun permissionCheck( + permission: String, + source: PlatformPlayer, + useFallbackPermissions: Boolean, + ): Boolean { + return if (useFallbackPermissions) { + source.isNativeAdmin.toBooleanOrElse(false) + } else { + source.hasPermission("${ServerChangelogs.NAMESPACE}.${permission}") + } + } + } +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.kt new file mode 100644 index 0000000..2d19bec --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.kt @@ -0,0 +1,89 @@ +package com.codingcat.changelogs.base.dialog.ui.editor + +import com.codingcat.changelogs.base.data.ChangelogEntry +import com.codingcat.changelogs.base.data.ChangelogStorage +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.minimessage.MiniMessage +import java.time.Instant + +sealed class EditorSession( + val entryUID: Int, +) { + val rawLines: MutableList = mutableListOf() + var editingLineIndex: Int = -1 + var currentLine: String = "" + var author: String = "" + var showRestoredMessage: Boolean = false + + @Throws(CommitException::class) + abstract fun commit(storage: ChangelogStorage) + + abstract val id: String + + abstract val permission: String + + abstract fun canBeSaved(): Boolean + + fun deserializeLines(): List { + return rawLines.map { MiniMessage.miniMessage().deserialize(it) } + } + + fun deserializeAuthor(): Component? { + return if (author.isNotBlank()) MiniMessage.miniMessage().deserialize(author) else null + } + + class Create(entryUID: Int) : EditorSession(entryUID) { + override val id: String = "create" + override val permission: String = "command.create" + + override fun canBeSaved(): Boolean = rawLines.isNotEmpty() || author.isNotBlank() + + @Throws(CommitException::class) + override fun commit(storage: ChangelogStorage) { + val newEntry = ChangelogEntry( + entryUID, + this.deserializeLines(), + Instant.now(), + this.deserializeAuthor(), + mutableSetOf(), + ) + runCatching { + storage.storeEntry(newEntry) + }.getOrElse { + throw CommitException("internal_error") + } + } + } + + class Edit(entry: ChangelogEntry) : EditorSession(entry.uid) { + init { + entry.lines.mapTo(rawLines) { MiniMessage.miniMessage().serialize(it) } + this.author = entry.author?.let { MiniMessage.miniMessage().serialize(it) } ?: "" + } + + override val id: String = "edit" + override val permission: String = "manage" + + override fun canBeSaved(): Boolean = false + + @Throws(CommitException::class) + override fun commit(storage: ChangelogStorage) { + val currentEntry: ChangelogEntry = storage.getByUID(entryUID) + ?: throw CommitException("entry_deleted") + val newEntry = ChangelogEntry( + currentEntry.uid, + this.deserializeLines(), + currentEntry.recordedAt, + this.deserializeAuthor(), + currentEntry.playersRead, + ) + runCatching { + storage.updateEntry(newEntry) + }.getOrElse { + throw CommitException("internal_error") + } + } + } + + class CommitException(val translationKeyPart: String) : Exception() +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/event/ChangelogJoinListener.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/event/ChangelogJoinListener.kt new file mode 100644 index 0000000..43ca6de --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/event/ChangelogJoinListener.kt @@ -0,0 +1,56 @@ +package com.codingcat.changelogs.base.event + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.base.dialog.DialogPackets +import com.codingcat.changelogs.base.dialog.DialogSessionManager +import com.codingcat.changelogs.base.dialog.PluginDialog +import com.codingcat.changelogs.base.dialog.ui.ChangelogDialog +import com.codingcat.changelogs.platformapi.event.PlatformEventManager +import com.codingcat.changelogs.platformapi.event.impl.PlayerEnterConfigurationPhaseEvent +import com.codingcat.changelogs.platformapi.event.impl.PlayerJoinEvent +import com.codingcat.changelogs.platformapi.event.registerIdentified +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import net.kyori.adventure.key.Key +import net.kyori.adventure.text.Component + +/** + * Event handler displaying unread changelogs to players upon joining or entering the configuration phase. + */ +object ChangelogJoinListener { + private val KEY: Key by lazy { ServerChangelogs.key("listener/changelog_display") } + + /** + * Registers the join listener with [manager] according to configured packet phase. + */ + fun registerEvents(manager: PlatformEventManager) { + val config = ServerChangelogs.pluginConfig() + when (config.dialogPacketPhase) { + DialogPackets.PacketPhase.PLAY -> manager.registerIdentified(KEY) { e -> + checkShowDialog(e.player, false) + } + + DialogPackets.PacketPhase.CONFIGURATION -> manager.registerIdentified( + KEY, + ) { e -> + if (checkShowDialog(e.player, true)) { + ServerChangelogs.info("console.join.frozen", Component.text(e.player.uniqueId.toString())) + DialogSessionManager.waitForUnfreeze(e.player) + } + } + } + } + + fun unregisterEvents(manager: PlatformEventManager) { + manager.unregisterIdentified(KEY) + } + + fun checkShowDialog(player: PlatformPlayer, freeze: Boolean): Boolean { + if (player.isFirstJoin) return false + val unreadChangelogs = ServerChangelogs.changelogStorage.listEntries().any { !it.hasRead(player) } + if (!unreadChangelogs) return false + val dialog = PluginDialog.Holder.getFromType() + if (freeze) DialogSessionManager.freeze(player) + dialog.showTo(player, DialogSessionManager, ServerChangelogs.pluginConfig().dialogPacketPhase) + return true + } +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/lang/TranslationSource.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/lang/TranslationSource.kt new file mode 100644 index 0000000..8be3834 --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/lang/TranslationSource.kt @@ -0,0 +1,209 @@ +package com.codingcat.changelogs.base.lang + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.platformapi.meta.ChangelogsMeta +import com.codingcat.changelogs.platformapi.meta.PlatformMeta +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import net.kyori.adventure.key.Key +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.Component.text +import net.kyori.adventure.text.ComponentLike +import net.kyori.adventure.text.TranslatableComponent +import net.kyori.adventure.text.minimessage.MiniMessage +import net.kyori.adventure.text.minimessage.tag.Tag +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver +import net.kyori.adventure.text.minimessage.translation.MiniMessageTranslationStore +import net.kyori.adventure.translation.GlobalTranslator +import net.kyori.adventure.translation.TranslationStore +import org.slf4j.kotlin.KLogger +import org.slf4j.kotlin.info +import org.slf4j.kotlin.warn +import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.error.YAMLException +import java.io.IOException +import java.nio.file.Path +import java.util.* +import kotlin.io.path.* + +@Suppress("UNCHECKED_CAST") +class TranslationSource( + private val sourceDirectory: Path, + private val changelogsMeta: ChangelogsMeta, + private val platformMeta: PlatformMeta, + private val logger: KLogger, +) { + private val storeKey: Key by lazy { ServerChangelogs.key("translations") } + private val miniMessage: MiniMessage by lazy { + MiniMessage.builder() + .tags(createGlobalTagResolver()) + .build() + } + private var translationStore: TranslationStore<*>? = null + + fun reload() { + logger.info { "Reloading translation store..." } + if (!sourceDirectory.exists()) { + logger.warn { + buildString { + append("Failed to find translation directory, no translations will be loaded! ") + append("(Restart the server to re-generate the defaults!)") + } + } + return + } + val files = sourceDirectory.listDirectoryEntries() + .filter { it.name.endsWith(".yml") || it.name.endsWith(".yaml") } + if (files.isEmpty()) { + logger.warn { + buildString { + append("No translation files present, unable to load language! ") + append("(Delete the \"lang\" directory and restart the server to re-generate the defaults!)") + } + } + return + } + val rawTranslations = mutableMapOf>() + for (file in files) { + val withoutExtension = file.nameWithoutExtension + val data = this.loadTranslationFile(file) ?: continue + rawTranslations[withoutExtension] = data + } + val globalTranslator = GlobalTranslator.translator() + this.translationStore?.let { globalTranslator.removeSource(it) } + val newStore = this.createTranslationStore(rawTranslations) + this.translationStore = newStore + globalTranslator.addSource(newStore) + } + + private fun createTranslationStore( + rawLanguageData: Map>, + ): TranslationStore<*> { + val store = MiniMessageTranslationStore.create(this.storeKey, this.miniMessage) + store.defaultLocale(Locale.US) + for ((rawKey, value) in rawLanguageData) { + val locale = runCatching { + var key = rawKey.replace("-", "_") + if (key.endsWith("_")) key = key.replace("_", "") + if (key.contains("_")) { + Locale.of( + key.substring(0, key.indexOf('_')).lowercase(), + key.substring(key.indexOf("_") + 1).uppercase(), + ) + } else { + Locale.of(key) + } + }.getOrElse { + logger.warn { "Skipping unknown language entry \"${rawKey}\"" } + continue + } + store.registerAll(locale, value) + } + return store + } + + private fun loadTranslationFile(path: Path): Map? { + val fileName = path.fileName.toString() + if (!path.exists()) { + logger.warn { "Failed to find language file ${fileName}, skipping language!" } + return null + } + val langFile = Yaml() + return try { + val data: Map = path.inputStream().use { stream -> + langFile.load(stream) + } + val translations = mutableMapOf() + this.addRawTranslationsRecursive(translations, "${ServerChangelogs.NAMESPACE}.", data) + translations + } catch (e: IOException) { + logger.warn(e) { "Failed to load language file ${fileName} due to I/O errors:" } + null + } catch (e: YAMLException) { + logger.warn(e) { "Invalid syntax in language file ${fileName}:" } + null + } + } + + private fun addRawTranslationsRecursive( + rawTranslations: MutableMap, + prefix: String, + data: Map, + ) { + data.forEach { (k, v) -> + if (v is String) rawTranslations["${prefix}${k}"] = v + if (v is Map<*, *>) { + addRawTranslationsRecursive( + rawTranslations, + "${prefix}${k}.", + v as Map, + ) + } + } + } + + private fun createGlobalTagResolver(): TagResolver { + return TagResolver.builder() + .resolver(TagResolver.standard()) + .tag( + "prefix", + Tag.inserting(translatable("prefix")), + ) + .tag("translate") { args, _ -> + Tag.selfClosingInserting( + translatable( + args.popOr("Missing translate argument").value(), + ), + ) + } + .tag("plugin") { args, context -> + val property = args.popOr("Missing argument for tag \"plugin\"").lowerValue() + val value: String? = when (property) { + "name" -> changelogsMeta.name + "description" -> changelogsMeta.description + "version" -> changelogsMeta.version + "authors" -> changelogsMeta.authors?.joinToString(", ") + else -> throw context.newException("Invalid argument \"${property}\"", args) + } + Tag.selfClosingInserting( + value?.let(::text) ?: translatable("meta.unknown"), + ) + } + .tag("platform") { args, context -> + val property = args.popOr("Missing argument for tag \"platform\"").lowerValue() + val value: Component = when (property) { + "name" -> platformMeta.name + "version" -> text(platformMeta.version) + else -> throw context.newException("Invalid argument \"${property}\"", args) + } + Tag.selfClosingInserting(value) + } + .build() + } + + companion object { + fun translatable( + key: String, + vararg args: ComponentLike, + ): TranslatableComponent { + return Component.translatable( + "${ServerChangelogs.NAMESPACE}.${key}", + *args, + ) + } + + /** + * Used in dialogs specifically since paper doesn't provide [GlobalTranslator] + * support for those yet (see [this issue](https://github.com/PaperMC/Paper/issues/12971)) + */ + fun translatableManual( + player: PlatformPlayer, + key: String, + vararg args: ComponentLike, + ): Component { + return GlobalTranslator.render( + translatable(key, *args), + player.clientLocale, + ) + } + } +} diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/util/ResourceUtil.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/util/ResourceUtil.kt new file mode 100644 index 0000000..888381e --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/util/ResourceUtil.kt @@ -0,0 +1,40 @@ +package com.codingcat.changelogs.base.util + +import java.io.IOException +import java.net.URISyntaxException +import java.net.URL +import java.nio.charset.StandardCharsets +import java.nio.file.FileSystems +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name + +object ResourceUtil { + private val classLoader: ClassLoader = ResourceUtil::class.java.classLoader + + fun readResourcesAsString(dirPath: String): Map = + listResources(dirPath).associateWith { resource -> + readResourceAsString("${dirPath}/${resource}") + } + + fun readResourceAsString(path: String): String = + try { + val stream = checkNotNull(classLoader.getResourceAsStream(path)) { "Resource not found: ${path}" } + stream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } + } catch (e: IOException) { + throw RuntimeException("Failed to read jar resource at ${path}", e) + } + + fun listResources(dirPath: String): Collection { + try { + val resourceUrl: URL = checkNotNull(classLoader.getResource(dirPath)) { "Resource not found" } + val uri = resourceUrl.toURI() + return FileSystems.newFileSystem(uri, emptyMap()).use { fileSystem -> + fileSystem.getPath(dirPath).listDirectoryEntries().map { it.name } + } + } catch (e: IOException) { + throw RuntimeException("Failed to list jar resources at ${dirPath}", e) + } catch (e: URISyntaxException) { + throw RuntimeException("Failed to list jar resources at ${dirPath}", e) + } + } +} diff --git a/base/src/main/resources/defaults/config.yml b/src/base/src/main/resources/defaults/config.yml similarity index 98% rename from base/src/main/resources/defaults/config.yml rename to src/base/src/main/resources/defaults/config.yml index c27abf4..f7f60ba 100644 --- a/base/src/main/resources/defaults/config.yml +++ b/src/base/src/main/resources/defaults/config.yml @@ -60,4 +60,4 @@ dialog_header_item: "minecraft:written_book" # -- DO NOT MODIFY UNLESS YOU KNOW WHAT YOU ARE DOING -- # Manually enable workarounds/fixes for certain bugs in e.g. PacketEvents # that normally don't apply to the current version -enable_manual_workarounds: [] \ No newline at end of file +enable_manual_workarounds: [ ] diff --git a/base/src/main/resources/lang/en_US.yml b/src/base/src/main/resources/lang/en_US.yml similarity index 100% rename from base/src/main/resources/lang/en_US.yml rename to src/base/src/main/resources/lang/en_US.yml diff --git a/base/src/main/resources/lang/ru_RU.yml b/src/base/src/main/resources/lang/ru_RU.yml similarity index 100% rename from base/src/main/resources/lang/ru_RU.yml rename to src/base/src/main/resources/lang/ru_RU.yml diff --git a/paper/build.gradle.kts b/src/paper/build.gradle.kts similarity index 78% rename from paper/build.gradle.kts rename to src/paper/build.gradle.kts index 02e47a1..6e4b789 100644 --- a/paper/build.gradle.kts +++ b/src/paper/build.gradle.kts @@ -1,7 +1,5 @@ plugins { - java - alias(libs.plugins.lombok) - alias(libs.plugins.shadow) + id("com.gradleup.shadow") alias(libs.plugins.run.paper) alias(libs.plugins.resource.factory.paper.convention) } @@ -13,10 +11,11 @@ repositories { @Suppress("VulnerableLibrariesLocal") dependencies { - implementation(project(":base")) - implementation(project(":platformapi")) + implementation(projects.base) + implementation(projects.platformapi) compileOnly(libs.packetevents.paper) compileOnly(libs.paper.api) + testImplementation(libs.kotlin.test) } paperPluginYaml { @@ -30,10 +29,6 @@ paperPluginYaml { dependencies.server.create("packetevents") } -java { - toolchain.languageVersion = JavaLanguageVersion.of(25) -} - tasks { runServer { minecraftVersion(project.property("minecraft_version").toString()) diff --git a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/PaperChangelogsPlatform.kt b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/PaperChangelogsPlatform.kt new file mode 100644 index 0000000..9ab8792 --- /dev/null +++ b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/PaperChangelogsPlatform.kt @@ -0,0 +1,82 @@ +package com.codingcat.changelogs.paper + +import com.codingcat.changelogs.base.Entrypoints +import com.codingcat.changelogs.paper.command.PaperCommandManager +import com.codingcat.changelogs.paper.event.PaperEventManager +import com.codingcat.changelogs.paper.item.PaperNativeItemManager +import com.codingcat.changelogs.paper.meta.PaperPlatformMeta +import com.codingcat.changelogs.paper.meta.PaperPluginChangelogsMeta +import com.codingcat.changelogs.paper.player.PaperPlayerManager +import com.codingcat.changelogs.platformapi.ChangelogsPlatform +import com.codingcat.changelogs.platformapi.Entrypoint +import net.kyori.adventure.text.logger.slf4j.ComponentLogger +import org.bukkit.plugin.java.JavaPlugin +import java.nio.file.Path + +/** + * Paper platform adapter and plugin implementation for ServerChangelogs. + */ +class PaperChangelogsPlatform : JavaPlugin(), ChangelogsPlatform { + companion object { + lateinit var instance: PaperChangelogsPlatform + private set + } + + init { + instance = this + } + + private val entrypoint: Entrypoint by lazy { Entrypoints.create(this) } + + override val platformMeta: PaperPlatformMeta = PaperPlatformMeta + + override val changelogsMeta: PaperPluginChangelogsMeta by lazy { PaperPluginChangelogsMeta(pluginMeta) } + + override val nativeItemManager: PaperNativeItemManager = PaperNativeItemManager + + override fun getComponentLogger(): ComponentLogger = super.getComponentLogger() + + override fun getDataPath(): Path = super.getDataPath() + + override val playerManager: PaperPlayerManager = PaperPlayerManager + + override val eventManager: PaperEventManager = PaperEventManager + + override val commandManager: PaperCommandManager = PaperCommandManager + + private var earlyInitSucceeded = false + + override fun onEnable() { + runCatching { + Class.forName("com.github.retrooper.packetevents.PacketEvents") + }.onFailure { + throw RuntimeException( + "Unable to find PacketEvents! Please ensure you have the latest version of that plugin installed!", + it, + ) + } + + earlyInitSucceeded = true + + runCatching { + entrypoint.onStart() + }.onFailure { + throw RuntimeException("Failed to start platform entrypoint", it) + } + + commandManager.registerEvents(lifecycleManager) + } + + override fun onDisable() { + if (!earlyInitSucceeded) return + try { + runCatching { + entrypoint.onShutdown() + }.onFailure { + throw RuntimeException("Failed to shut down platform entrypoint", it) + } + } finally { + eventManager.unregisterAll() + } + } +} diff --git a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/command/PaperCommandManager.kt b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/command/PaperCommandManager.kt new file mode 100644 index 0000000..e90d9b6 --- /dev/null +++ b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/command/PaperCommandManager.kt @@ -0,0 +1,40 @@ +package com.codingcat.changelogs.paper.command + +import com.codingcat.changelogs.paper.player.PaperPlayerManager +import com.codingcat.changelogs.platformapi.command.PlatformCommandManager +import com.codingcat.changelogs.platformapi.command.PlatformCommandSource +import com.mojang.brigadier.tree.LiteralCommandNode +import io.papermc.paper.command.brigadier.CommandSourceStack +import io.papermc.paper.command.brigadier.Commands +import io.papermc.paper.plugin.lifecycle.event.LifecycleEventManager +import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents +import kotlinx.collections.immutable.ImmutableSet +import org.bukkit.plugin.Plugin + +@Suppress("UNCHECKED_CAST") +object PaperCommandManager : PlatformCommandManager { + private val registrarActions: MutableSet<(Commands) -> Unit> = HashSet() + + override fun register(node: LiteralCommandNode<*>, aliases: ImmutableSet) { + val commandNode = node as LiteralCommandNode + registrarActions.add { commands: Commands -> commands.register(commandNode, aliases) } + } + + @Throws(IllegalArgumentException::class) + override fun adaptPlatformSource(platformCommandSource: Any): PlatformCommandSource { + require(platformCommandSource is CommandSourceStack) { + "Expected native paper CommandSourceStack but got ${platformCommandSource}" + } + return WrappedCommandSourceStack(platformCommandSource, PaperPlayerManager) + } + + fun registerEvents(lifecycleEventManager: LifecycleEventManager) { + lifecycleEventManager.registerEventHandler( + LifecycleEvents.COMMANDS, + ) { commands -> + this.registrarActions.forEach { action -> + action(commands.registrar()) + } + } + } +} diff --git a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/command/WrappedCommandSourceStack.kt b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/command/WrappedCommandSourceStack.kt new file mode 100644 index 0000000..2785c3a --- /dev/null +++ b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/command/WrappedCommandSourceStack.kt @@ -0,0 +1,28 @@ +package com.codingcat.changelogs.paper.command + +import com.codingcat.changelogs.paper.player.PaperPlayerManager +import com.codingcat.changelogs.platformapi.command.PlatformCommandSource +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import io.papermc.paper.command.brigadier.CommandSourceStack +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.permission.PermissionChecker +import org.bukkit.entity.Player + +class WrappedCommandSourceStack( + private val sourceStack: CommandSourceStack, + private val playerManager: PaperPlayerManager, +) : PlatformCommandSource { + override val executingPlayer: PlatformPlayer? + get() = when (val p = sourceStack.sender) { + is Player -> playerManager.fromNative(p) + else -> null + } + + override fun asAudience(): Audience { + return sourceStack.sender + } + + override fun asPermissionChecker(): PermissionChecker { + return PermissionChecker { permission: String -> sourceStack.sender.permissionValue(permission) } + } +} diff --git a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/event/PaperEventManager.kt b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/event/PaperEventManager.kt new file mode 100644 index 0000000..9bb0a9e --- /dev/null +++ b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/event/PaperEventManager.kt @@ -0,0 +1,68 @@ +package com.codingcat.changelogs.paper.event + +import com.codingcat.changelogs.paper.PaperChangelogsPlatform +import com.codingcat.changelogs.paper.player.PaperPlayerManager +import com.codingcat.changelogs.platformapi.event.PlatformEventManager +import com.codingcat.changelogs.platformapi.event.impl.PlatformEvent +import com.codingcat.changelogs.platformapi.event.util.PlatformEventMappings +import org.bukkit.Bukkit +import org.bukkit.event.* +import org.bukkit.plugin.EventExecutor + +@Suppress("UNCHECKED_CAST") +object PaperEventManager : PlatformEventManager { + private val listenerSet: MutableSet> = HashSet() + private val eventMappings: PaperEventMappings = PaperEventMappings(PaperPlayerManager) + + override fun registerListener(eventCls: Class, listener: (T) -> Unit) { + val mapping = eventMappings.findForPlatformEvent(eventCls) + val mappingListener = PlatformMappingListener(listener, mapping) + Bukkit.getPluginManager().registerEvent( + mapping.nativeEventCls, + mappingListener, + EventPriority.NORMAL, + mappingListener, + PaperChangelogsPlatform.instance, + ) + this.listenerSet.add(mappingListener) + } + + override fun unregisterListener(listener: Any) { + this.unregisterIf { it === listener || it == listener } + } + + override fun unregisterIf(listenerPredicate: (Any) -> Boolean) { + val iterator = listenerSet.iterator() + while (iterator.hasNext()) { + val mappingListener = iterator.next() + if (listenerPredicate(mappingListener.listener)) { + HandlerList.unregisterAll(mappingListener) + iterator.remove() + } + } + } + + override fun unregisterAll() { + this.listenerSet.forEach { HandlerList.unregisterAll(it) } + this.listenerSet.clear() + } + + private class PlatformMappingListener( + val listener: (T) -> Unit, + private val mapping: PlatformEventMappings.Mapping, + ) : Listener, EventExecutor { + @Throws(EventException::class) + override fun execute(listener: Listener, event: Event) { + runCatching { + val platformEvent: T = (mapping as PlatformEventMappings.Mapping) + .convertToPlatform(event) + this.listener(platformEvent) + }.getOrElse { e -> + throw EventException( + e, + "Failed to handle event ${event} (platform ${mapping.platformEventCls})", + ) + } + } + } +} diff --git a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/event/PaperEventMappings.kt b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/event/PaperEventMappings.kt new file mode 100644 index 0000000..6ba2acb --- /dev/null +++ b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/event/PaperEventMappings.kt @@ -0,0 +1,34 @@ +package com.codingcat.changelogs.paper.event + +import com.codingcat.changelogs.paper.player.PaperConfigurationPhaseConnectionWrapper +import com.codingcat.changelogs.paper.player.PaperPlayerManager +import com.codingcat.changelogs.platformapi.event.impl.PlayerEnterConfigurationPhaseEvent +import com.codingcat.changelogs.platformapi.event.impl.PlayerJoinEvent +import com.codingcat.changelogs.platformapi.event.util.PlatformEventMappings +import io.papermc.paper.event.connection.configuration.AsyncPlayerConnectionConfigureEvent +import org.bukkit.event.Event + +class PaperEventMappings( + playerManager: PaperPlayerManager, +) : PlatformEventMappings() { + init { + register( + PlayerJoinEvent::class.java, + org.bukkit.event.player.PlayerJoinEvent::class.java, + ) { ev -> + PlayerJoinEvent( + playerManager.fromNative(ev.player), + true, + ) + } + register( + PlayerEnterConfigurationPhaseEvent::class.java, + AsyncPlayerConnectionConfigureEvent::class.java, + ) { ev -> + PlayerEnterConfigurationPhaseEvent( + PaperConfigurationPhaseConnectionWrapper(ev.connection), + true, + ) + } + } +} diff --git a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/item/PaperNativeItemManager.kt b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/item/PaperNativeItemManager.kt new file mode 100644 index 0000000..44a0fc7 --- /dev/null +++ b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/item/PaperNativeItemManager.kt @@ -0,0 +1,38 @@ +package com.codingcat.changelogs.paper.item + +import com.codingcat.changelogs.platformapi.item.NativeItemManager +import io.github.retrooper.packetevents.util.SpigotConversionUtil +import io.papermc.paper.registry.RegistryAccess +import io.papermc.paper.registry.RegistryKey +import net.kyori.adventure.key.Key +import org.bukkit.Bukkit +import org.bukkit.inventory.ItemStack +import org.bukkit.inventory.ItemType +import com.github.retrooper.packetevents.protocol.item.ItemStack as PEItemStack + +object PaperNativeItemManager : NativeItemManager { + @Throws(IllegalArgumentException::class) + override fun adaptToPEStack(nativeStack: Any): PEItemStack { + require(nativeStack is ItemStack) { "Expected native bukkit ItemStack but got ${nativeStack}" } + return SpigotConversionUtil.fromBukkitItemStack(nativeStack) + } + + override fun createNativeStack(key: Key, amount: Int): Any? { + val type: ItemType? = RegistryAccess.registryAccess().getRegistry(RegistryKey.ITEM).get(key) + return type?.createItemStack(amount) + } + + @Throws(IllegalArgumentException::class) + override fun applyComponentStr(nativeStack: Any, componentStr: String): Any { + require(nativeStack is ItemStack) { "Expected native bukkit ItemStack but got ${nativeStack}" } + val itemKey = nativeStack.type.key.asString() + val created = Bukkit.getItemFactory().createItemStack( + buildString { + append(itemKey) + append(componentStr) + }, + ) + created.amount = nativeStack.amount + return created + } +} diff --git a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/meta/PaperPlatformMeta.kt b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/meta/PaperPlatformMeta.kt new file mode 100644 index 0000000..e50898a --- /dev/null +++ b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/meta/PaperPlatformMeta.kt @@ -0,0 +1,24 @@ +package com.codingcat.changelogs.paper.meta + +import com.codingcat.changelogs.platformapi.meta.PlatformMeta +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.TextComponent +import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.text.format.TextColor +import org.bukkit.Bukkit + +object PaperPlatformMeta : PlatformMeta { + override val name: TextComponent + get() { + val serverName: String = Bukkit.getName() + val color: TextColor = when { + serverName.contains("purpur", true) -> NamedTextColor.LIGHT_PURPLE + serverName.contains("folia", true) -> NamedTextColor.GREEN + else -> NamedTextColor.AQUA + } + return Component.text(serverName, color) + } + + override val version: String + get() = Bukkit.getVersion() +} diff --git a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/meta/PaperPluginChangelogsMeta.kt b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/meta/PaperPluginChangelogsMeta.kt new file mode 100644 index 0000000..d39e704 --- /dev/null +++ b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/meta/PaperPluginChangelogsMeta.kt @@ -0,0 +1,20 @@ +package com.codingcat.changelogs.paper.meta + +import com.codingcat.changelogs.platformapi.meta.ChangelogsMeta +import io.papermc.paper.plugin.configuration.PluginMeta + +class PaperPluginChangelogsMeta( + private val pluginMeta: PluginMeta, +) : ChangelogsMeta { + override val name: String + get() = pluginMeta.name + + override val version: String + get() = pluginMeta.version + + override val description: String? + get() = pluginMeta.description + + override val authors: Set? + get() = pluginMeta.authors.takeIf { it.isNotEmpty() }?.toSet() +} diff --git a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/player/PaperConfigurationPhaseConnectionWrapper.kt b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/player/PaperConfigurationPhaseConnectionWrapper.kt new file mode 100644 index 0000000..4be8433 --- /dev/null +++ b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/player/PaperConfigurationPhaseConnectionWrapper.kt @@ -0,0 +1,56 @@ +@file:Suppress("UnstableApiUsage") + +package com.codingcat.changelogs.paper.player + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.destroystokyo.paper.ClientOption +import com.github.retrooper.packetevents.protocol.player.User +import io.papermc.paper.connection.PlayerConfigurationConnection +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.permission.PermissionChecker +import net.kyori.adventure.text.Component +import net.kyori.adventure.util.TriState +import org.bukkit.Bukkit +import java.util.* + +class PaperConfigurationPhaseConnectionWrapper( + private val connection: PlayerConfigurationConnection, +) : PlatformPlayer { + override val uniqueId: UUID + get() = connection.profile.id ?: error("Player UUID was null") + + override val name: String + get() = connection.profile.name ?: "Unknown" + + override val clientLocale: Locale + get() = Locale.of(connection.getClientOption(ClientOption.LOCALE)) + + override val isFirstJoin: Boolean = false + + override val isNativeAdmin: TriState = TriState.NOT_SET + + override fun kick(reason: Component?) { + connection.disconnect(reason ?: Component.empty()) + } + + override fun asPacketEventsUser(): User { + val channel = ServerChangelogs.packetEventsApi.protocolManager.getChannel(uniqueId) + return ServerChangelogs.packetEventsApi.protocolManager.getUser(channel) + ?: error("Player channel was null") + } + + override fun asAudience(): Audience { + return connection.audience + } + + override fun asPermissionChecker(): PermissionChecker { + return Bukkit.getPlayer(uniqueId)?.let { player -> + PermissionChecker { permission: String -> player.permissionValue(permission) } + } ?: PermissionChecker.always(TriState.NOT_SET) + } + + fun unwrapNative(): Any { + return connection + } +} diff --git a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/player/PaperPlayerManager.kt b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/player/PaperPlayerManager.kt new file mode 100644 index 0000000..4bc912e --- /dev/null +++ b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/player/PaperPlayerManager.kt @@ -0,0 +1,28 @@ +package com.codingcat.changelogs.paper.player + +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.codingcat.changelogs.platformapi.player.PlatformPlayerManager +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toImmutableSet +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import java.util.* + +object PaperPlayerManager : PlatformPlayerManager { + override val onlinePlayers: ImmutableSet + get() = Bukkit.getOnlinePlayers().map { fromNative(it) }.toImmutableSet() + + override fun getFromUUID(uuid: UUID): PlatformPlayer? { + return Bukkit.getPlayer(uuid)?.let { fromNative(it) } + } + + override fun getFromName(name: String): PlatformPlayer? { + return Bukkit.getPlayer(name)?.let { fromNative(it) } + } + + @Throws(IllegalArgumentException::class) + override fun fromNative(nativePlayer: Any): PlatformPlayer { + require(nativePlayer is Player) { "Expected bukkit Player but got ${nativePlayer}" } + return PaperPlayerWrapper(nativePlayer) + } +} diff --git a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/player/PaperPlayerWrapper.kt b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/player/PaperPlayerWrapper.kt new file mode 100644 index 0000000..2e23c4b --- /dev/null +++ b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/player/PaperPlayerWrapper.kt @@ -0,0 +1,39 @@ +package com.codingcat.changelogs.paper.player + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.github.retrooper.packetevents.protocol.player.User +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.permission.PermissionChecker +import net.kyori.adventure.text.Component +import net.kyori.adventure.util.TriState +import org.bukkit.entity.Player +import org.bukkit.event.player.PlayerKickEvent +import java.util.* + +class PaperPlayerWrapper( + private val player: Player, +) : PlatformPlayer { + override val uniqueId: UUID = player.uniqueId + override val name: String get() = player.name + override val clientLocale: Locale = player.locale() + override val isFirstJoin: Boolean = !player.hasPlayedBefore() + override val isNativeAdmin: TriState = TriState.byBoolean(player.isOp) + + override fun kick(reason: Component?) { + player.kick(reason, PlayerKickEvent.Cause.PLUGIN) + } + + override fun asPacketEventsUser(): User { + return ServerChangelogs.packetEventsApi.playerManager.getUser(this.player) + ?: error("PacketEvents player was null") + } + + override fun asAudience(): Audience { + return this.player + } + + override fun asPermissionChecker(): PermissionChecker { + return PermissionChecker { permission: String -> this.player.permissionValue(permission) } + } +} diff --git a/src/platformapi/build.gradle.kts b/src/platformapi/build.gradle.kts new file mode 100644 index 0000000..ed9312f --- /dev/null +++ b/src/platformapi/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + `java-library` +} + +dependencies { + api(libs.kotlinx.immutable) + compileOnly(libs.bundles.adventure) + compileOnly(libs.packetevents.api) + compileOnly(libs.mojang.brigadier) + compileOnly(libs.slf4j.api) + testImplementation(libs.kotlin.test) +} diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/ChangelogsPlatform.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/ChangelogsPlatform.kt new file mode 100644 index 0000000..c7d0911 --- /dev/null +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/ChangelogsPlatform.kt @@ -0,0 +1,57 @@ +package com.codingcat.changelogs.platformapi + +import com.codingcat.changelogs.platformapi.command.PlatformCommandManager +import com.codingcat.changelogs.platformapi.event.PlatformEventManager +import com.codingcat.changelogs.platformapi.item.NativeItemManager +import com.codingcat.changelogs.platformapi.meta.ChangelogsMeta +import com.codingcat.changelogs.platformapi.meta.PlatformMeta +import com.codingcat.changelogs.platformapi.player.PlatformPlayerManager +import net.kyori.adventure.text.logger.slf4j.ComponentLogger +import java.nio.file.Path + +/** + * Platform abstraction contract hosting server-specific services, lifecycle hooks, and metadata. + */ +interface ChangelogsPlatform { + /** + * Platform logger supporting Adventure component rendering. + * Defined as a method to match Paper's [org.bukkit.plugin.Plugin.getComponentLogger] and avoid JVM accidental override errors. + */ + fun getComponentLogger(): ComponentLogger + + /** + * Resolves the root data directory path dedicated to storing plugin configuration and changelogs. + * Defined as a method to match Paper's [org.bukkit.plugin.Plugin.getDataPath] and avoid JVM accidental override errors. + */ + fun getDataPath(): Path + + /** + * Manager handling player lookups and adapting native player instances to platform representations. + */ + val playerManager: PlatformPlayerManager + + /** + * Manager handling subscription and dispatch of cross-platform events. + */ + val eventManager: PlatformEventManager + + /** + * Manager responsible for registering Brigadier command nodes with the underlying platform. + */ + val commandManager: PlatformCommandManager + + /** + * Manager providing native item stack conversion and validation services. + */ + val nativeItemManager: NativeItemManager + + /** + * Metadata describing the host server environment. + */ + val platformMeta: PlatformMeta + + /** + * Metadata describing this plugin distribution instance. + */ + val changelogsMeta: ChangelogsMeta +} diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/Entrypoint.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/Entrypoint.kt new file mode 100644 index 0000000..cd1c3af --- /dev/null +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/Entrypoint.kt @@ -0,0 +1,28 @@ +package com.codingcat.changelogs.platformapi + +import com.github.retrooper.packetevents.PacketEventsAPI + +/** + * Common entrypoint contract for the core plugin lifecycle managed by a [ChangelogsPlatform]. + */ +interface Entrypoint { + /** + * Active host platform providing services and metadata. + */ + val platform: ChangelogsPlatform + + /** + * PacketEvents API instance. + */ + val packetEventsApi: PacketEventsAPI + + /** + * Initializes configurations, storage backends, and command/event registrations on startup. + */ + fun onStart() + + /** + * Shuts down storage, flushes pending state, and releases registered resources. + */ + fun onShutdown() +} diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/audience/PermissionAudienceWrapper.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/audience/PermissionAudienceWrapper.kt new file mode 100644 index 0000000..4019dc4 --- /dev/null +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/audience/PermissionAudienceWrapper.kt @@ -0,0 +1,40 @@ +package com.codingcat.changelogs.platformapi.audience + +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.permission.PermissionChecker +import net.kyori.adventure.util.TriState + +/** + * Compound contract combining Adventure [Audience] message reception with [PermissionChecker] validation. + */ +interface PermissionAudienceWrapper { + /** + * Views this receiver as an Adventure [Audience]. + */ + fun asAudience(): Audience + + /** + * Views this receiver as an Adventure [PermissionChecker]. + */ + fun asPermissionChecker(): PermissionChecker + + /** + * Checks whether the receiver holds the specified permission node. + */ + fun hasPermission(permission: String): Boolean { + return asPermissionChecker().test(permission) + } + + /** + * Evaluates permission presence while treating unset ([TriState.NOT_SET]) status as granted if [trueIfUnset] is true. + */ + fun hasPermission(permission: String, trueIfUnset: Boolean): Boolean { + val value = asPermissionChecker().value(permission) + return value == TriState.TRUE || (trueIfUnset && value == TriState.NOT_SET) + } + + /** + * Operator overload allowing idiomatic Kotlin `'permission.node' in source` checks. + */ + operator fun contains(permission: String): Boolean = hasPermission(permission) +} diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/command/PlatformCommandManager.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/command/PlatformCommandManager.kt new file mode 100644 index 0000000..8b14372 --- /dev/null +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/command/PlatformCommandManager.kt @@ -0,0 +1,30 @@ +package com.codingcat.changelogs.platformapi.command + +import com.mojang.brigadier.tree.LiteralCommandNode +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf + +/** + * Abstraction responsible for registering Brigadier command nodes and adapting platform command sources. + */ +interface PlatformCommandManager { + /** + * Registers a root command node without aliases. + */ + fun register(node: LiteralCommandNode<*>) { + this.register(node, persistentSetOf()) + } + + /** + * Registers a root command node alongside the provided set of alias labels. + */ + fun register(node: LiteralCommandNode<*>, aliases: ImmutableSet) + + /** + * Adapts an underlying native command source (e.g., Paper `CommandSourceStack` or Velocity `CommandSource`) + * to a platform-neutral [PlatformCommandSource]. + * + * @throws IllegalArgumentException if the provided source instance is not of an expected native type. + */ + fun adaptPlatformSource(platformCommandSource: Any): PlatformCommandSource +} diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/command/PlatformCommandSource.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/command/PlatformCommandSource.kt new file mode 100644 index 0000000..b214ff1 --- /dev/null +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/command/PlatformCommandSource.kt @@ -0,0 +1,14 @@ +package com.codingcat.changelogs.platformapi.command + +import com.codingcat.changelogs.platformapi.audience.PermissionAudienceWrapper +import com.codingcat.changelogs.platformapi.player.PlatformPlayer + +/** + * Abstraction wrapping an invocation source capable of receiving messages, checking permissions, and identifying an optional executing player. + */ +interface PlatformCommandSource : PermissionAudienceWrapper { + /** + * The player executing this command, or `null` if the execution originated from console or automated source. + */ + val executingPlayer: PlatformPlayer? +} diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/IdentifiedListener.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/IdentifiedListener.kt new file mode 100644 index 0000000..b488dea --- /dev/null +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/IdentifiedListener.kt @@ -0,0 +1,24 @@ +package com.codingcat.changelogs.platformapi.event + +import net.kyori.adventure.key.Key +import net.kyori.adventure.key.Keyed + +/** + * Event listener tagged with an Adventure [Key] for grouped identification and lifecycle unregistration. + */ +interface IdentifiedListener : (T) -> Unit, Keyed { + companion object { + /** + * Wraps an existing [listener] with an identifying [key]. + */ + fun create(key: Key, listener: (T) -> Unit): IdentifiedListener { + return object : IdentifiedListener { + override fun invoke(event: T) { + listener(event) + } + + override fun key(): Key = key + } + } + } +} diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/PlatformEventManager.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/PlatformEventManager.kt new file mode 100644 index 0000000..e662349 --- /dev/null +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/PlatformEventManager.kt @@ -0,0 +1,68 @@ +package com.codingcat.changelogs.platformapi.event + +import com.codingcat.changelogs.platformapi.event.impl.PlatformEvent +import com.codingcat.changelogs.platformapi.event.util.MethodDispatchingListener +import net.kyori.adventure.key.Key + +/** + * Manager handling subscription, identification, and lifecycle cleanup of platform-neutral event listeners. + */ +interface PlatformEventManager { + /** + * Binds all listener methods discovered on a [MethodDispatchingListener] instance to this manager. + */ + fun registerMethodDispatcher(methodDispatcher: MethodDispatchingListener) { + methodDispatcher.setupAll(this) + } + + /** + * Registers a listener tagged with a unique [key], enabling targeted unregistration later. + */ + fun registerIdentified(eventCls: Class, key: Key, listener: (T) -> Unit) { + this.registerListener(eventCls, IdentifiedListener.create(key, listener)) + } + + /** + * Subscribes a listener to events of type [eventCls]. + */ + fun registerListener(eventCls: Class, listener: (T) -> Unit) + + /** + * Unregisters the exact listener instance. + */ + fun unregisterListener(listener: Any) + + /** + * Removes all registered [IdentifiedListener] instances associated with [identifier]. + */ + fun unregisterIdentified(identifier: Key) { + this.unregisterIf { e -> e is IdentifiedListener<*> && e.key() == identifier } + } + + /** + * Unregisters any active listeners matching [listenerPredicate]. + */ + fun unregisterIf(listenerPredicate: (Any) -> Boolean) + + /** + * Removes all active event subscriptions. + */ + fun unregisterAll() +} + +/** + * Reified helper for registering an anonymous listener for [T]. + */ +inline fun PlatformEventManager.registerListener(noinline listener: (T) -> Unit) { + registerListener(T::class.java, listener) +} + +/** + * Reified helper for registering an identified listener tagged with [key] for [T]. + */ +inline fun PlatformEventManager.registerIdentified( + key: Key, + noinline listener: (T) -> Unit, +) { + registerIdentified(T::class.java, key, listener) +} diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/impl/PlatformEvent.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/impl/PlatformEvent.kt new file mode 100644 index 0000000..520e65d --- /dev/null +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/impl/PlatformEvent.kt @@ -0,0 +1,6 @@ +package com.codingcat.changelogs.platformapi.event.impl + +/** + * Marker interface for all cross-platform events. + */ +interface PlatformEvent diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/impl/PlayerEnterConfigurationPhaseEvent.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/impl/PlayerEnterConfigurationPhaseEvent.kt new file mode 100644 index 0000000..0426fdd --- /dev/null +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/impl/PlayerEnterConfigurationPhaseEvent.kt @@ -0,0 +1,14 @@ +package com.codingcat.changelogs.platformapi.event.impl + +import com.codingcat.changelogs.platformapi.player.PlatformPlayer + +/** + * Dispatched when a player enters the network configuration phase. + * + * Platform implementations should handle this event asynchronously; blocking the handler thread + * causes the connection to remain waiting in the configuration phase. + */ +class PlayerEnterConfigurationPhaseEvent( + val player: PlatformPlayer, + val initialConfiguration: Boolean, +) : PlatformEvent diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/impl/PlayerJoinEvent.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/impl/PlayerJoinEvent.kt new file mode 100644 index 0000000..ad0bb13 --- /dev/null +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/impl/PlayerJoinEvent.kt @@ -0,0 +1,11 @@ +package com.codingcat.changelogs.platformapi.event.impl + +import com.codingcat.changelogs.platformapi.player.PlatformPlayer + +/** + * Dispatched when a player connects or transitions into the active play phase. + */ +class PlayerJoinEvent( + val player: PlatformPlayer, + val justConnected: Boolean, +) : PlatformEvent diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/util/MethodDispatchingListener.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/util/MethodDispatchingListener.kt new file mode 100644 index 0000000..bda749e --- /dev/null +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/util/MethodDispatchingListener.kt @@ -0,0 +1,88 @@ +package com.codingcat.changelogs.platformapi.event.util + +import com.codingcat.changelogs.platformapi.event.PlatformEventManager +import com.codingcat.changelogs.platformapi.event.impl.PlatformEvent +import java.lang.reflect.InvocationTargetException + +/** + * Interface enabling reflective discovery and binding of methods annotated with [ListenerMethod] to a [PlatformEventManager]. + */ +interface MethodDispatchingListener { + /** + * Inspects declared methods on this instance and registers those annotated with [ListenerMethod] to [eventManager]. + * + * @throws IllegalArgumentException if an annotated method does not take exactly one [PlatformEvent] parameter. + */ + fun setupAll(eventManager: PlatformEventManager) { + javaClass.declaredMethods.forEach { method -> + if (!method.isAnnotationPresent(ListenerMethod::class.java)) return@forEach + + require(method.parameterCount == 1) { + "Listener method ${method} has an invalid amount of parameters" + } + + val paramType = method.parameterTypes[0] + require(paramType != PlatformEvent::class.java && PlatformEvent::class.java.isAssignableFrom(paramType)) { + buildString { + append("Listener method ${method} parameter has to be a valid implementation ") + append("of PlatformEvent, got ${paramType} instead") + } + } + + method.isAccessible = true + + val handler = object : IdentifiableEventListener { + override val origin: MethodDispatchingListener = this@MethodDispatchingListener + + override fun invoke(event: PlatformEvent) { + try { + method.invoke(this@MethodDispatchingListener, event) + } catch (e: ReflectiveOperationException) { + val cause = if (e is InvocationTargetException) e.cause ?: e else e + throw RuntimeException( + "Failed to invoke method dispatching listener method ${method} for event ${event}", + cause, + ) + } + } + } + + registerEventHelper(eventManager, paramType, handler) + } + } + + /** + * Unregisters all listener subscriptions registered by this instance from [eventManager]. + */ + fun unregisterAll(eventManager: PlatformEventManager) { + eventManager.unregisterIf { listener -> + listener is IdentifiableEventListener<*> && listener.origin === this + } + } + + /** + * Marks a function within a [MethodDispatchingListener] as an event handler method. + */ + @Target(AnnotationTarget.FUNCTION) + @Retention(AnnotationRetention.RUNTIME) + annotation class ListenerMethod + + /** + * Listener token tracking the originating [MethodDispatchingListener] instance for teardown filtering. + */ + interface IdentifiableEventListener : (T) -> Unit { + val origin: MethodDispatchingListener + } +} + +@Suppress("UNCHECKED_CAST") +private fun registerEventHelper( + eventManager: PlatformEventManager, + eventType: Class<*>, + handler: MethodDispatchingListener.IdentifiableEventListener, +) { + eventManager.registerListener( + eventType as Class, + handler as (T) -> Unit, + ) +} diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappings.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappings.kt new file mode 100644 index 0000000..ca268ae --- /dev/null +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappings.kt @@ -0,0 +1,48 @@ +package com.codingcat.changelogs.platformapi.event.util + +import com.codingcat.changelogs.platformapi.event.impl.PlatformEvent + +/** + * Registry holding bidirectional type mappings between platform-neutral [PlatformEvent] types and platform-native events [E]. + */ +@Suppress("UNCHECKED_CAST") +abstract class PlatformEventMappings { + private val eventMappings = mutableSetOf>() + + /** + * Registers a mapping translating native event [nativeEventCls] into platform event [platformEventCls] using [mapper]. + */ + protected fun register( + platformEventCls: Class, + nativeEventCls: Class, + mapper: (N) -> T, + ) { + eventMappings += Mapping(platformEventCls, nativeEventCls, mapper) + } + + /** + * Locates the registered mapping for [eventCls]. + * + * @throws IllegalArgumentException if no mapping exists for the requested event class. + */ + fun findForPlatformEvent(eventCls: Class): Mapping { + val mapping = eventMappings.firstOrNull { it.platformEventCls == eventCls } + ?: throw IllegalArgumentException("Unable to find matching native event for ${eventCls}") + + return mapping as Mapping + } + + /** + * Descriptor pairing a platform event class [PL] and native event class [PA] with a conversion function. + */ + data class Mapping( + val platformEventCls: Class, + val nativeEventCls: Class, + val mapper: (PA) -> PL, + ) { + /** + * Transforms the supplied [nativeEvent] into its platform-neutral representation. + */ + fun convertToPlatform(nativeEvent: PA): PL = mapper(nativeEvent) + } +} diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/item/NativeItemManager.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/item/NativeItemManager.kt new file mode 100644 index 0000000..00f2e79 --- /dev/null +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/item/NativeItemManager.kt @@ -0,0 +1,25 @@ +package com.codingcat.changelogs.platformapi.item + +import com.github.retrooper.packetevents.protocol.item.ItemStack +import net.kyori.adventure.key.Key + +/** + * Abstraction bridging platform-native item stack representations with PacketEvents item stacks. + */ +interface NativeItemManager { + /** + * Converts a platform-native item stack into a PacketEvents [ItemStack]. + */ + fun adaptToPEStack(nativeStack: Any): ItemStack + + /** + * Constructs a native item stack for the item identifier [key] with the specified [amount]. + * Returns `null` if the item type is unknown to the underlying platform. + */ + fun createNativeStack(key: Key, amount: Int): Any? + + /** + * Parses and applies a SNBT or JSON component string to the target native item stack. + */ + fun applyComponentStr(nativeStack: Any, componentStr: String): Any +} diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/meta/ChangelogsMeta.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/meta/ChangelogsMeta.kt new file mode 100644 index 0000000..c5eecf5 --- /dev/null +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/meta/ChangelogsMeta.kt @@ -0,0 +1,26 @@ +package com.codingcat.changelogs.platformapi.meta + +/** + * Metadata defining the ServerChangelogs plugin distribution. + */ +interface ChangelogsMeta { + /** + * Plugin display name. + */ + val name: String + + /** + * Semantic version string of the plugin. + */ + val version: String + + /** + * Brief summary describing the plugin purpose. + */ + val description: String? + + /** + * Set of author names credited with developing the plugin. + */ + val authors: Set? +} diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/meta/PlatformMeta.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/meta/PlatformMeta.kt new file mode 100644 index 0000000..ce029a8 --- /dev/null +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/meta/PlatformMeta.kt @@ -0,0 +1,18 @@ +package com.codingcat.changelogs.platformapi.meta + +import net.kyori.adventure.text.Component + +/** + * Environmental details of the hosting platform. + */ +interface PlatformMeta { + /** + * Styled platform brand component (e.g. Paper, Folia, Purpur, Velocity). + */ + val name: Component + + /** + * Platform version string. + */ + val version: String +} diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/player/PlatformPlayer.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/player/PlatformPlayer.kt new file mode 100644 index 0000000..729fff5 --- /dev/null +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/player/PlatformPlayer.kt @@ -0,0 +1,46 @@ +package com.codingcat.changelogs.platformapi.player + +import com.codingcat.changelogs.platformapi.audience.PermissionAudienceWrapper +import net.kyori.adventure.text.Component +import net.kyori.adventure.util.TriState +import java.util.* + +/** + * Common platform abstraction representing a connected player across Paper and Velocity servers. + */ +interface PlatformPlayer : PermissionAudienceWrapper { + /** + * Unique identifier of this player. + */ + val uniqueId: UUID + + /** + * Current username of this player. + */ + val name: String + + /** + * Active client locale configured by the player's Minecraft client. + */ + val clientLocale: Locale + + /** + * Indicates whether this connection represents the player's initial join to the server. + */ + val isFirstJoin: Boolean + + /** + * Native administrator status (e.g. Bukkit `isOp()`), returning [TriState.NOT_SET] if unsupported by the platform. + */ + val isNativeAdmin: TriState + + /** + * Disconnects the player from the network with an optional disconnect reason component. + */ + fun kick(reason: Component?) + + /** + * Resolves the underlying PacketEvents `User` associated with this player's active network channel. + */ + fun asPacketEventsUser(): Any +} diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/player/PlatformPlayerManager.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/player/PlatformPlayerManager.kt new file mode 100644 index 0000000..bdfc862 --- /dev/null +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/player/PlatformPlayerManager.kt @@ -0,0 +1,42 @@ +package com.codingcat.changelogs.platformapi.player + +import kotlinx.collections.immutable.ImmutableSet +import java.util.* + +/** + * Manager responsible for querying online players and adapting platform-native player representations. + */ +interface PlatformPlayerManager { + /** + * Immutable snapshot of currently connected players. + */ + val onlinePlayers: ImmutableSet + + /** + * Finds a connected player by their unique [uuid], returning `null` if not found. + */ + fun getFromUUID(uuid: UUID): PlatformPlayer? + + /** + * Finds a connected player by their username [name], returning `null` if not found. + */ + fun getFromName(name: String): PlatformPlayer? + + /** + * Operator shorthand for lookup by [uuid]. + */ + operator fun get(uuid: UUID): PlatformPlayer? = getFromUUID(uuid) + + /** + * Operator shorthand for lookup by [name]. + */ + operator fun get(name: String): PlatformPlayer? = getFromName(name) + + /** + * Wraps a platform-native player instance (e.g. Bukkit `Player` or Velocity `Player`) into [PlatformPlayer]. + * + * @throws IllegalArgumentException if the provided native instance cannot be handled. + */ + @Throws(IllegalArgumentException::class) + fun fromNative(nativePlayer: Any): PlatformPlayer +} diff --git a/velocity/build.gradle.kts b/src/velocity/build.gradle.kts similarity index 71% rename from velocity/build.gradle.kts rename to src/velocity/build.gradle.kts index 3a3be57..0478086 100644 --- a/velocity/build.gradle.kts +++ b/src/velocity/build.gradle.kts @@ -1,9 +1,7 @@ import xyz.jpenilla.resourcefactory.velocity.VelocityPluginJson plugins { - id("java") - alias(libs.plugins.lombok) - alias(libs.plugins.shadow) + id("com.gradleup.shadow") alias(libs.plugins.run.velocity) alias(libs.plugins.resource.factory.velocity.convention) } @@ -15,26 +13,23 @@ repositories { @Suppress("VulnerableLibrariesLocal") dependencies { - implementation(project(":base")) - implementation(project(":platformapi")) + implementation(projects.base) + implementation(projects.platformapi) compileOnly(libs.packetevents.velocity) compileOnly(libs.velocity.api) + testImplementation(libs.kotlin.test) } velocityPluginJson { id = "server-changelogs" - name = rootProject.name - url = project.property("url") as String + name = "${rootProject.name}-${project.name}" + url = project.findProperty("url")?.toString() ?: missingProperty("url") main = "${group}.changelogs.velocity.VelocityChangelogsPlatform" authors.addAll("codingcat2468") dependencies.addAll(VelocityPluginJson.Dependency("packetevents", false)) } -java { - toolchain.languageVersion = JavaLanguageVersion.of(25) -} - tasks { runVelocity { velocityVersion(libs.versions.velocity.api.get()) diff --git a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/VelocityChangelogsPlatform.kt b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/VelocityChangelogsPlatform.kt new file mode 100644 index 0000000..6d9b0e7 --- /dev/null +++ b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/VelocityChangelogsPlatform.kt @@ -0,0 +1,102 @@ +package com.codingcat.changelogs.velocity + +import com.codingcat.changelogs.base.Entrypoints +import com.codingcat.changelogs.base.compat.PacketEventsNativeItemManager +import com.codingcat.changelogs.platformapi.ChangelogsPlatform +import com.codingcat.changelogs.platformapi.Entrypoint +import com.codingcat.changelogs.velocity.command.VelocityCommandManager +import com.codingcat.changelogs.velocity.event.VelocityEventManager +import com.codingcat.changelogs.velocity.meta.VelocityPlatformMeta +import com.codingcat.changelogs.velocity.meta.VelocityPluginChangelogsMeta +import com.codingcat.changelogs.velocity.player.VelocityPlayerManager +import com.github.retrooper.packetevents.PacketEvents +import com.github.retrooper.packetevents.PacketEventsAPI +import com.google.inject.Inject +import com.velocitypowered.api.event.Subscribe +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent +import com.velocitypowered.api.event.proxy.ProxyPreShutdownEvent +import com.velocitypowered.api.plugin.PluginContainer +import com.velocitypowered.api.plugin.annotation.DataDirectory +import com.velocitypowered.api.proxy.ProxyServer +import net.kyori.adventure.text.logger.slf4j.ComponentLogger +import java.nio.file.Path + +/** + * Velocity platform adapter and plugin implementation for ServerChangelogs. + */ +class VelocityChangelogsPlatform @Inject constructor( + server: ProxyServer, + private val componentLogger: ComponentLogger, + @DataDirectory private val dataDirectory: Path, + pluginContainer: PluginContainer, +) : ChangelogsPlatform { + private val entrypoint: Entrypoint = Entrypoints.create(this) + + override fun getComponentLogger(): ComponentLogger = componentLogger + + override fun getDataPath(): Path = dataDirectory + + override val platformMeta: VelocityPlatformMeta = VelocityPlatformMeta(server.version) + + override val changelogsMeta: VelocityPluginChangelogsMeta = + VelocityPluginChangelogsMeta(pluginContainer.description) + + override val playerManager: VelocityPlayerManager = VelocityPlayerManager.apply { + this.server = server + } + + override val eventManager: VelocityEventManager by lazy { + VelocityEventManager.apply { + this.eventManager = server.eventManager + this.platform = this@VelocityChangelogsPlatform + } + } + + override val nativeItemManager: PacketEventsNativeItemManager by lazy { PacketEventsNativeItemManager } + + override val commandManager: VelocityCommandManager = VelocityCommandManager.apply { + this.commandManager = server.commandManager + this.platform = this@VelocityChangelogsPlatform + } + + private var earlyInitSucceeded = false + + @Subscribe + fun onProxyInitialization(event: ProxyInitializeEvent) { + runCatching { + Class.forName("com.github.retrooper.packetevents.PacketEvents") + }.onFailure { + throw RuntimeException( + "Unable to find PacketEvents! Please ensure you have the latest version of that plugin installed!", + it, + ) + } + + earlyInitSucceeded = true + + runCatching { + entrypoint.onStart() + }.onFailure { + throw RuntimeException("Failed to start platform entrypoint", it) + } + } + + @Subscribe + @Suppress("UnstableApiUsage") + fun onProxyPreShutdown(event: ProxyPreShutdownEvent) { + if (!earlyInitSucceeded) return + try { + runCatching { + entrypoint.onShutdown() + }.onFailure { + throw RuntimeException("Failed to shut down platform entrypoint", it) + } + } finally { + eventManager.unregisterAll() + } + } + + companion object { + val packetEventsApi: PacketEventsAPI by lazy { PacketEvents.getAPI() } + } +} diff --git a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/command/VelocityCommandManager.kt b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/command/VelocityCommandManager.kt new file mode 100644 index 0000000..b4147d4 --- /dev/null +++ b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/command/VelocityCommandManager.kt @@ -0,0 +1,34 @@ +package com.codingcat.changelogs.velocity.command + +import com.codingcat.changelogs.platformapi.command.PlatformCommandManager +import com.codingcat.changelogs.platformapi.command.PlatformCommandSource +import com.codingcat.changelogs.velocity.VelocityChangelogsPlatform +import com.codingcat.changelogs.velocity.player.VelocityPlayerManager +import com.mojang.brigadier.tree.LiteralCommandNode +import com.velocitypowered.api.command.BrigadierCommand +import com.velocitypowered.api.command.CommandManager +import com.velocitypowered.api.command.CommandMeta +import com.velocitypowered.api.command.CommandSource +import kotlinx.collections.immutable.ImmutableSet + +@Suppress("UNCHECKED_CAST") +object VelocityCommandManager : PlatformCommandManager { + lateinit var commandManager: CommandManager + lateinit var platform: VelocityChangelogsPlatform + override fun register(node: LiteralCommandNode<*>, aliases: ImmutableSet) { + val command = BrigadierCommand(node as LiteralCommandNode) + val commandMeta: CommandMeta = commandManager.metaBuilder(command) + .aliases(*aliases.toTypedArray()) + .plugin(platform) + .build() + commandManager.register(commandMeta, command) + } + + @Throws(IllegalArgumentException::class) + override fun adaptPlatformSource(platformCommandSource: Any): PlatformCommandSource { + require(platformCommandSource is CommandSource) { + "Expected native velocity CommandSource but got ${platformCommandSource}" + } + return WrappedCommandSource(platformCommandSource, VelocityPlayerManager) + } +} diff --git a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/command/WrappedCommandSource.kt b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/command/WrappedCommandSource.kt new file mode 100644 index 0000000..a844942 --- /dev/null +++ b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/command/WrappedCommandSource.kt @@ -0,0 +1,25 @@ +package com.codingcat.changelogs.velocity.command + +import com.codingcat.changelogs.platformapi.command.PlatformCommandSource +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.codingcat.changelogs.velocity.player.VelocityPlayerManager +import com.velocitypowered.api.command.CommandSource +import com.velocitypowered.api.proxy.Player +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.permission.PermissionChecker + +class WrappedCommandSource( + private val commandSource: CommandSource, + private val playerManager: VelocityPlayerManager, +) : PlatformCommandSource { + override val executingPlayer: PlatformPlayer? + get() = (commandSource as? Player)?.let { playerManager.fromNative(it) } + + override fun asAudience(): Audience { + return commandSource + } + + override fun asPermissionChecker(): PermissionChecker { + return commandSource.permissionChecker + } +} diff --git a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/event/VelocityEventManager.kt b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/event/VelocityEventManager.kt new file mode 100644 index 0000000..4ca6cf3 --- /dev/null +++ b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/event/VelocityEventManager.kt @@ -0,0 +1,75 @@ +package com.codingcat.changelogs.velocity.event + +import com.codingcat.changelogs.platformapi.event.PlatformEventManager +import com.codingcat.changelogs.platformapi.event.impl.PlatformEvent +import com.codingcat.changelogs.platformapi.event.util.PlatformEventMappings +import com.codingcat.changelogs.velocity.VelocityChangelogsPlatform +import com.codingcat.changelogs.velocity.player.VelocityPlayerManager +import com.velocitypowered.api.event.AwaitingEventExecutor +import com.velocitypowered.api.event.EventManager +import com.velocitypowered.api.event.EventTask + +@Suppress("UNCHECKED_CAST") +object VelocityEventManager : PlatformEventManager { + lateinit var eventManager: EventManager + lateinit var platform: VelocityChangelogsPlatform + + private val handlerSet: MutableSet> = HashSet() + private val eventMappings: VelocityEventMappings = VelocityEventMappings(VelocityPlayerManager) + + override fun registerListener(eventCls: Class, listener: (T) -> Unit) { + val mapping = eventMappings.findForPlatformEvent(eventCls) + val mappingHandler = PlatformMappingHandler(listener, mapping) + + val nativeClass = mapping.nativeEventCls as Class + this.eventManager.register(platform, nativeClass, mappingHandler) + this.handlerSet.add(mappingHandler) + } + + override fun unregisterListener(listener: Any) { + this.unregisterIf { it === listener || it == listener } + } + + override fun unregisterIf(listenerPredicate: (Any) -> Boolean) { + val iterator = handlerSet.iterator() + while (iterator.hasNext()) { + val handler = iterator.next() + if (listenerPredicate(handler.listener)) { + this.eventManager.unregister(platform, handler) + iterator.remove() + } + } + } + + override fun unregisterAll() { + this.handlerSet.forEach { handler -> this.eventManager.unregister(platform, handler) } + this.handlerSet.clear() + } + + private class PlatformMappingHandler( + val listener: (T) -> Unit, + private val mapping: PlatformEventMappings.Mapping, + ) : AwaitingEventExecutor { + override fun execute(event: Any) { + executeInternal(event) + } + + override fun executeAsync(event: Any): EventTask { + return EventTask.async { + executeInternal(event) + } + } + + private fun executeInternal(event: Any) { + runCatching { + val platformEvent: T = (mapping as PlatformEventMappings.Mapping).convertToPlatform(event) + this.listener(platformEvent) + }.getOrElse { e -> + throw RuntimeException( + "Failed to handle event ${event} (platform ${mapping.platformEventCls})", + e, + ) + } + } + } +} diff --git a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/event/VelocityEventMappings.kt b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/event/VelocityEventMappings.kt new file mode 100644 index 0000000..2dfdee7 --- /dev/null +++ b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/event/VelocityEventMappings.kt @@ -0,0 +1,33 @@ +package com.codingcat.changelogs.velocity.event + +import com.codingcat.changelogs.platformapi.event.impl.PlayerEnterConfigurationPhaseEvent +import com.codingcat.changelogs.platformapi.event.impl.PlayerJoinEvent +import com.codingcat.changelogs.platformapi.event.util.PlatformEventMappings +import com.codingcat.changelogs.velocity.player.VelocityPlayerManager +import com.velocitypowered.api.event.player.ServerPostConnectEvent +import com.velocitypowered.api.event.player.configuration.PlayerConfigurationEvent + +class VelocityEventMappings( + playerManager: VelocityPlayerManager, +) : PlatformEventMappings() { + init { + register( + PlayerJoinEvent::class.java, + ServerPostConnectEvent::class.java, + ) { event -> + PlayerJoinEvent( + playerManager.fromNative(event.player), + event.previousServer == null, + ) + } + register( + PlayerEnterConfigurationPhaseEvent::class.java, + PlayerConfigurationEvent::class.java, + ) { event -> + PlayerEnterConfigurationPhaseEvent( + playerManager.fromNative(event.player()), + event.server().previousServer.isEmpty, + ) + } + } +} diff --git a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/meta/VelocityPlatformMeta.kt b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/meta/VelocityPlatformMeta.kt new file mode 100644 index 0000000..9090d40 --- /dev/null +++ b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/meta/VelocityPlatformMeta.kt @@ -0,0 +1,17 @@ +package com.codingcat.changelogs.velocity.meta + +import com.codingcat.changelogs.platformapi.meta.PlatformMeta +import com.velocitypowered.api.util.ProxyVersion +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.Component.text +import net.kyori.adventure.text.format.NamedTextColor + +class VelocityPlatformMeta( + private val proxyVersion: ProxyVersion, +) : PlatformMeta { + override val name: Component + get() = text(proxyVersion.name, NamedTextColor.RED) + + override val version: String + get() = proxyVersion.version +} diff --git a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/meta/VelocityPluginChangelogsMeta.kt b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/meta/VelocityPluginChangelogsMeta.kt new file mode 100644 index 0000000..a7cf6c1 --- /dev/null +++ b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/meta/VelocityPluginChangelogsMeta.kt @@ -0,0 +1,21 @@ +package com.codingcat.changelogs.velocity.meta + +import com.codingcat.changelogs.platformapi.meta.ChangelogsMeta +import com.velocitypowered.api.plugin.PluginDescription +import kotlin.jvm.optionals.getOrNull + +class VelocityPluginChangelogsMeta( + private val pluginDescription: PluginDescription, +) : ChangelogsMeta { + override val name: String + get() = pluginDescription.name.orElseGet { pluginDescription.id } + + override val version: String + get() = pluginDescription.version.getOrNull() ?: "Unknown" + + override val description: String? + get() = pluginDescription.description.getOrNull() + + override val authors: Set? + get() = pluginDescription.authors.takeIf { it.isNotEmpty() }?.toSet() +} diff --git a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/player/VelocityPlayerManager.kt b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/player/VelocityPlayerManager.kt new file mode 100644 index 0000000..cbd6b3a --- /dev/null +++ b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/player/VelocityPlayerManager.kt @@ -0,0 +1,32 @@ +package com.codingcat.changelogs.velocity.player + +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.codingcat.changelogs.platformapi.player.PlatformPlayerManager +import com.velocitypowered.api.proxy.Player +import com.velocitypowered.api.proxy.ProxyServer +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toImmutableSet +import java.util.* +import kotlin.jvm.optionals.getOrNull + +object VelocityPlayerManager : PlatformPlayerManager { + lateinit var server: ProxyServer + override val onlinePlayers: ImmutableSet + get() = server.allPlayers.map { fromNative(it) }.toImmutableSet() + + override fun getFromUUID(uuid: UUID): PlatformPlayer? { + return server.getPlayer(uuid).getOrNull()?.let { fromNative(it) } + } + + override fun getFromName(name: String): PlatformPlayer? { + return server.getPlayer(name).getOrNull()?.let { fromNative(it) } + } + + @Throws(IllegalArgumentException::class) + override fun fromNative(nativePlayer: Any): PlatformPlayer { + require(nativePlayer is Player) { + "Expected native velocity player but got ${nativePlayer}" + } + return VelocityPlayerWrapper(nativePlayer) + } +} diff --git a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/player/VelocityPlayerWrapper.kt b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/player/VelocityPlayerWrapper.kt new file mode 100644 index 0000000..9b027db --- /dev/null +++ b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/player/VelocityPlayerWrapper.kt @@ -0,0 +1,45 @@ +package com.codingcat.changelogs.velocity.player + +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.codingcat.changelogs.velocity.VelocityChangelogsPlatform +import com.github.retrooper.packetevents.protocol.player.User +import com.velocitypowered.api.proxy.Player +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.permission.PermissionChecker +import net.kyori.adventure.text.Component +import net.kyori.adventure.util.TriState +import java.util.* + +class VelocityPlayerWrapper( + private val player: Player, +) : PlatformPlayer { + override val uniqueId: UUID + get() = player.uniqueId + + override val name: String + get() = player.username + + override val clientLocale: Locale + get() = player.playerSettings?.locale ?: Locale.getDefault() + + override val isFirstJoin: Boolean = false + + override val isNativeAdmin: TriState = TriState.NOT_SET + + override fun kick(reason: Component?) { + player.disconnect(reason ?: Component.empty()) + } + + override fun asPacketEventsUser(): User { + return VelocityChangelogsPlatform.packetEventsApi.playerManager.getUser(player) + ?: error("PacketEvents player was null") + } + + override fun asAudience(): Audience { + return player + } + + override fun asPermissionChecker(): PermissionChecker { + return player.permissionChecker + } +} diff --git a/velocity/src/main/java/com/codingcat/changelogs/velocity/VelocityChangelogsPlatform.java b/velocity/src/main/java/com/codingcat/changelogs/velocity/VelocityChangelogsPlatform.java deleted file mode 100644 index ea5f8ba..0000000 --- a/velocity/src/main/java/com/codingcat/changelogs/velocity/VelocityChangelogsPlatform.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.codingcat.changelogs.velocity; - -import com.codingcat.changelogs.base.Entrypoints; -import com.codingcat.changelogs.base.compat.PacketEventsNativeItemManager; -import com.codingcat.changelogs.platformapi.ChangelogsPlatform; -import com.codingcat.changelogs.platformapi.Entrypoint; -import com.codingcat.changelogs.velocity.command.VelocityCommandManager; -import com.codingcat.changelogs.velocity.event.VelocityEventManager; -import com.codingcat.changelogs.velocity.meta.VelocityPlatformMeta; -import com.codingcat.changelogs.velocity.meta.VelocityPluginChangelogsMeta; -import com.codingcat.changelogs.velocity.player.VelocityPlayerManager; -import com.google.inject.Inject; -import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; -import com.velocitypowered.api.event.Subscribe; -import com.velocitypowered.api.event.proxy.ProxyPreShutdownEvent; -import com.velocitypowered.api.plugin.PluginContainer; -import com.velocitypowered.api.plugin.annotation.DataDirectory; -import com.velocitypowered.api.proxy.ProxyServer; -import lombok.Getter; -import net.kyori.adventure.text.logger.slf4j.ComponentLogger; -import org.jetbrains.annotations.NotNull; - -import java.nio.file.Path; - -public class VelocityChangelogsPlatform implements ChangelogsPlatform { - private final @NotNull Entrypoint entrypoint = Entrypoints.create(this); - - private final @Getter ComponentLogger componentLogger; - private final @Getter Path dataPath; - - private final @Getter VelocityPlatformMeta platformMeta; - private final @Getter VelocityPluginChangelogsMeta changelogsMeta; - private final @Getter VelocityPlayerManager playerManager; - private final @Getter VelocityEventManager eventManager; - private final @Getter PacketEventsNativeItemManager nativeItemManager; - private final @Getter VelocityCommandManager commandManager; - - private boolean earlyInitSucceeded = false; - - @Inject - public VelocityChangelogsPlatform(@NotNull ProxyServer server, @NotNull ComponentLogger componentLogger, @NotNull @DataDirectory Path dataDirectory, @NotNull PluginContainer pluginContainer) { - this.componentLogger = componentLogger; - this.dataPath = dataDirectory; - this.platformMeta = new VelocityPlatformMeta(server.getVersion()); - this.changelogsMeta = new VelocityPluginChangelogsMeta(pluginContainer.getDescription()); - this.playerManager = new VelocityPlayerManager(server); - this.eventManager = new VelocityEventManager(server.getEventManager(), this); - this.nativeItemManager = new PacketEventsNativeItemManager(); - this.commandManager = new VelocityCommandManager(server.getCommandManager(), this, this.playerManager); - } - - @Subscribe - public void onProxyInitialization(@NotNull ProxyInitializeEvent event) { - try { - Class.forName("com.github.retrooper.packetevents.PacketEvents"); - } catch (ClassNotFoundException | NoClassDefFoundError e) { - throw new RuntimeException("Unable to find PacketEvents! Please ensure you have the latest version of that plugin installed!", e); - } - this.eventManager.initMappings(getPlayerManager()); - this.earlyInitSucceeded = true; - try { - this.entrypoint.onStart(); - } catch (Throwable e) { - throw new RuntimeException("Failed to start platform entrypoint", e); - } - } - - @Subscribe - @SuppressWarnings("UnstableApiUsage") - public void onProxyPreShutdown(@NotNull ProxyPreShutdownEvent event) { - if (!this.earlyInitSucceeded) return; - try { - this.entrypoint.onShutdown(); - } catch (Throwable e) { - throw new RuntimeException("Failed to shut down platform entrypoint", e); - } finally { - this.eventManager.unregisterAll(); - } - } -} - diff --git a/velocity/src/main/java/com/codingcat/changelogs/velocity/command/VelocityCommandManager.java b/velocity/src/main/java/com/codingcat/changelogs/velocity/command/VelocityCommandManager.java deleted file mode 100644 index 16ca56f..0000000 --- a/velocity/src/main/java/com/codingcat/changelogs/velocity/command/VelocityCommandManager.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.codingcat.changelogs.velocity.command; - -import com.codingcat.changelogs.platformapi.command.ICommandManager; -import com.codingcat.changelogs.platformapi.command.ICommandSource; -import com.codingcat.changelogs.velocity.VelocityChangelogsPlatform; -import com.codingcat.changelogs.velocity.player.VelocityPlayerManager; -import com.mojang.brigadier.tree.LiteralCommandNode; -import com.velocitypowered.api.command.BrigadierCommand; -import com.velocitypowered.api.command.CommandManager; -import com.velocitypowered.api.command.CommandMeta; -import com.velocitypowered.api.command.CommandSource; -import lombok.RequiredArgsConstructor; -import org.jetbrains.annotations.NotNull; - -import java.util.Set; - -@RequiredArgsConstructor -public class VelocityCommandManager implements ICommandManager { - private final @NotNull CommandManager commandManager; - private final @NotNull VelocityChangelogsPlatform platform; - private final @NotNull VelocityPlayerManager playerManager; - - @Override - public void register(@NotNull LiteralCommandNode node, @NotNull Set aliases) { - //noinspection unchecked - BrigadierCommand command = new BrigadierCommand((LiteralCommandNode) node); - CommandMeta commandMeta = this.commandManager.metaBuilder(command) - .aliases(aliases.toArray(String[]::new)) - .plugin(platform) - .build(); - this.commandManager.register(commandMeta, command); - } - - @Override - public @NotNull ICommandSource adaptPlatformSource(@NotNull Object platformCommandSource) throws IllegalArgumentException { - if (!(platformCommandSource instanceof CommandSource source)) - throw new IllegalArgumentException("Expected native velocity CommandSource but got" + platformCommandSource); - return new WrappedCommandSource(source, playerManager); - } -} diff --git a/velocity/src/main/java/com/codingcat/changelogs/velocity/command/WrappedCommandSource.java b/velocity/src/main/java/com/codingcat/changelogs/velocity/command/WrappedCommandSource.java deleted file mode 100644 index d552fa0..0000000 --- a/velocity/src/main/java/com/codingcat/changelogs/velocity/command/WrappedCommandSource.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.codingcat.changelogs.velocity.command; - -import com.codingcat.changelogs.platformapi.command.ICommandSource; -import com.codingcat.changelogs.platformapi.player.IPlayer; -import com.codingcat.changelogs.velocity.player.VelocityPlayerManager; -import com.velocitypowered.api.command.CommandSource; -import com.velocitypowered.api.proxy.Player; -import lombok.RequiredArgsConstructor; -import net.kyori.adventure.audience.Audience; -import net.kyori.adventure.permission.PermissionChecker; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -@RequiredArgsConstructor -public class WrappedCommandSource implements ICommandSource { - private final @NotNull CommandSource commandSource; - private final @NotNull VelocityPlayerManager playerManager; - - @Override - public @Nullable IPlayer getExecutingPlayer() { - return this.commandSource instanceof Player p ? playerManager.fromNative(p) : null; - } - - @Override - public @NotNull Audience asAudience() { - return this.commandSource; - } - - @Override - public @NotNull PermissionChecker asPermissionChecker() { - return this.commandSource.getPermissionChecker(); - } - - @Override - public @NotNull Object unwrapNative() { - return this.commandSource; - } -} diff --git a/velocity/src/main/java/com/codingcat/changelogs/velocity/event/VelocityEventManager.java b/velocity/src/main/java/com/codingcat/changelogs/velocity/event/VelocityEventManager.java deleted file mode 100644 index 9087d13..0000000 --- a/velocity/src/main/java/com/codingcat/changelogs/velocity/event/VelocityEventManager.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.codingcat.changelogs.velocity.event; - -import com.codingcat.changelogs.platformapi.event.IEventManager; -import com.codingcat.changelogs.platformapi.event.impl.IEvent; -import com.codingcat.changelogs.platformapi.event.util.PlatformEventMappings; -import com.codingcat.changelogs.velocity.VelocityChangelogsPlatform; -import com.codingcat.changelogs.velocity.player.VelocityPlayerManager; -import com.velocitypowered.api.event.EventHandler; -import com.velocitypowered.api.event.EventManager; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.HashSet; -import java.util.Objects; -import java.util.Set; -import java.util.function.Consumer; -import java.util.function.Predicate; - -@RequiredArgsConstructor -public class VelocityEventManager implements IEventManager { - private final @NotNull EventManager eventManager; - private final @NotNull VelocityChangelogsPlatform platform; - private final @NotNull Set> handlerSet = new HashSet<>(); - private @Nullable VelocityEventMappings eventMappings; - - public void initMappings(@NotNull VelocityPlayerManager playerManager) { - this.eventMappings = new VelocityEventMappings(playerManager); - } - - @Override - public void registerListener(@NotNull Class eventCls, @NotNull Consumer listener) { - VelocityEventMappings mappings = Objects.requireNonNull(eventMappings, "Event mappings not initialized"); - //noinspection unchecked - PlatformEventMappings.Mapping mapping = (PlatformEventMappings.Mapping) mappings.findForPlatformEvent(eventCls); - PlatformMappingHandler mappingHandler = new PlatformMappingHandler<>(listener, mapping); - this.eventManager.register(platform, mapping.nativeEventCls(), mappingHandler); - this.handlerSet.add(mappingHandler); - } - - @Override - public void unregisterListener(@NotNull Consumer listener) { - this.unregisterIf(l -> l.equals(listener)); - } - - @Override - public void unregisterIf(@NotNull Predicate> listenerPredicate) { - Predicate> predicate = l -> listenerPredicate.test(l.listener); - this.handlerSet.stream() - .filter(predicate) - .forEach(h -> this.eventManager.unregister(platform, h)); - this.handlerSet.removeIf(predicate); - } - - @Override - public void unregisterAll() { - this.handlerSet.forEach(h -> eventManager.unregister(platform,h)); - this.handlerSet.clear(); - } - - @RequiredArgsConstructor - private static final class PlatformMappingHandler implements EventHandler { - private final @Getter Consumer listener; - private final @NotNull PlatformEventMappings.Mapping mapping; - - @Override - public void execute(@NotNull Object event) { - try { - //noinspection unchecked - T platformEvent = ((PlatformEventMappings.Mapping) mapping).convertToPlatform(event); - this.listener.accept(platformEvent); - } catch (Throwable e) { - throw new RuntimeException("Failed to handle event " + event + " (platform " + mapping.platformEventCls() + ")", e); - } - } - } -} diff --git a/velocity/src/main/java/com/codingcat/changelogs/velocity/event/VelocityEventMappings.java b/velocity/src/main/java/com/codingcat/changelogs/velocity/event/VelocityEventMappings.java deleted file mode 100644 index 74a257e..0000000 --- a/velocity/src/main/java/com/codingcat/changelogs/velocity/event/VelocityEventMappings.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.codingcat.changelogs.velocity.event; - -import com.codingcat.changelogs.platformapi.event.impl.PlayerEnterConfigurationPhaseEvent; -import com.codingcat.changelogs.platformapi.event.impl.PlayerJoinEvent; -import com.codingcat.changelogs.platformapi.event.util.PlatformEventMappings; -import com.codingcat.changelogs.velocity.player.VelocityPlayerManager; -import com.velocitypowered.api.event.player.ServerPostConnectEvent; -import com.velocitypowered.api.event.player.configuration.PlayerConfigurationEvent; -import org.jetbrains.annotations.NotNull; - -public class VelocityEventMappings extends PlatformEventMappings { - VelocityEventMappings(@NotNull VelocityPlayerManager playerManager) { - this.register( - PlayerJoinEvent.class, - ServerPostConnectEvent.class, - e -> new PlayerJoinEvent(playerManager.fromNative(e.getPlayer()), e.getPreviousServer() == null) - ); - this.register( - PlayerEnterConfigurationPhaseEvent.class, - PlayerConfigurationEvent.class, - e -> new PlayerEnterConfigurationPhaseEvent(playerManager.fromNative(e.player()), e.server().getPreviousServer().isEmpty()) - ); - } -} diff --git a/velocity/src/main/java/com/codingcat/changelogs/velocity/meta/VelocityPlatformMeta.java b/velocity/src/main/java/com/codingcat/changelogs/velocity/meta/VelocityPlatformMeta.java deleted file mode 100644 index 63ee7d5..0000000 --- a/velocity/src/main/java/com/codingcat/changelogs/velocity/meta/VelocityPlatformMeta.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.codingcat.changelogs.velocity.meta; - -import com.codingcat.changelogs.platformapi.meta.PlatformMeta; -import com.velocitypowered.api.util.ProxyVersion; -import lombok.RequiredArgsConstructor; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.jetbrains.annotations.NotNull; - -import static net.kyori.adventure.text.Component.text; - -@RequiredArgsConstructor -public class VelocityPlatformMeta implements PlatformMeta { - private final @NotNull ProxyVersion proxyVersion; - - @Override - public @NotNull Component getName() { - return text(proxyVersion.getName(), NamedTextColor.RED); - } - - @Override - public @NotNull String getVersion() { - return proxyVersion.getVersion(); - } -} diff --git a/velocity/src/main/java/com/codingcat/changelogs/velocity/meta/VelocityPluginChangelogsMeta.java b/velocity/src/main/java/com/codingcat/changelogs/velocity/meta/VelocityPluginChangelogsMeta.java deleted file mode 100644 index 0c4e27a..0000000 --- a/velocity/src/main/java/com/codingcat/changelogs/velocity/meta/VelocityPluginChangelogsMeta.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.codingcat.changelogs.velocity.meta; - -import com.codingcat.changelogs.platformapi.meta.ChangelogsMeta; -import com.velocitypowered.api.plugin.PluginDescription; -import lombok.RequiredArgsConstructor; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.HashSet; -import java.util.Set; - -@RequiredArgsConstructor -public class VelocityPluginChangelogsMeta implements ChangelogsMeta { - private final @NotNull PluginDescription pluginDescription; - - @Override - public @NotNull String getName() { - return pluginDescription.getName().orElseGet(pluginDescription::getId); - } - - @Override - public @NotNull String getVersion() { - return pluginDescription.getVersion().orElse("Unknown"); - } - - @Override - public @Nullable String getDescription() { - return pluginDescription.getDescription().orElse(null); - } - - @Override - public @Nullable Set getAuthors() { - return new HashSet<>(pluginDescription.getAuthors()); - } -} diff --git a/velocity/src/main/java/com/codingcat/changelogs/velocity/player/VelocityPlayerManager.java b/velocity/src/main/java/com/codingcat/changelogs/velocity/player/VelocityPlayerManager.java deleted file mode 100644 index b789c31..0000000 --- a/velocity/src/main/java/com/codingcat/changelogs/velocity/player/VelocityPlayerManager.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.codingcat.changelogs.velocity.player; - -import com.codingcat.changelogs.platformapi.player.IPlayer; -import com.codingcat.changelogs.platformapi.player.IPlayerManager; -import com.velocitypowered.api.proxy.Player; -import com.velocitypowered.api.proxy.ProxyServer; -import lombok.RequiredArgsConstructor; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Set; -import java.util.UUID; -import java.util.stream.Collectors; - -@RequiredArgsConstructor -public class VelocityPlayerManager implements IPlayerManager { - private final @NotNull ProxyServer server; - - @Override - public @NotNull Set getOnlinePlayers() { - return this.server.getAllPlayers().stream() - .map(this::fromNative) - .collect(Collectors.toUnmodifiableSet()); - } - - @Override - public @Nullable IPlayer getFromUUID(@NotNull UUID uuid) { - return this.server.getPlayer(uuid).map(this::fromNative).orElse(null); - } - - @Override - public @Nullable IPlayer getFromName(@NotNull String name) { - return this.server.getPlayer(name).map(this::fromNative).orElse(null); - } - - @Override - public @NotNull IPlayer fromNative(@NotNull Object nativePlayer) throws IllegalArgumentException { - if (!(nativePlayer instanceof Player player)) - throw new IllegalArgumentException("Expected native velocity player but got " + nativePlayer); - return new VelocityPlayerWrapper(player); - } -} diff --git a/velocity/src/main/java/com/codingcat/changelogs/velocity/player/VelocityPlayerWrapper.java b/velocity/src/main/java/com/codingcat/changelogs/velocity/player/VelocityPlayerWrapper.java deleted file mode 100644 index 77dae40..0000000 --- a/velocity/src/main/java/com/codingcat/changelogs/velocity/player/VelocityPlayerWrapper.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.codingcat.changelogs.velocity.player; - -import com.codingcat.changelogs.platformapi.player.IPlayer; -import com.github.retrooper.packetevents.PacketEvents; -import com.velocitypowered.api.proxy.Player; -import com.velocitypowered.api.proxy.player.PlayerSettings; -import lombok.RequiredArgsConstructor; -import net.kyori.adventure.audience.Audience; -import net.kyori.adventure.permission.PermissionChecker; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.util.TriState; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Locale; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; - -@RequiredArgsConstructor -public class VelocityPlayerWrapper implements IPlayer { - private final @NotNull Player player; - - @Override - public @NotNull UUID getUniqueId() { - return this.player.getUniqueId(); - } - - @Override - public @NotNull Locale getClientLocale() { - return Optional.ofNullable(this.player.getPlayerSettings()) - .map(PlayerSettings::getLocale) - .orElseGet(Locale::getDefault); - } - - @Override - public boolean isFirstJoin() { - return false; // TODO: Either implement a different system at the base level or make an implementation for velocity - } - - @Override - public @NotNull TriState isNativeAdmin() { - return TriState.NOT_SET; - } - - @Override - public void kick(@Nullable Component reason) { - this.player.disconnect(reason != null ? reason : Component.empty()); - } - - @Override - public @NotNull Object asPacketEventsUser() { - return Objects.requireNonNull(PacketEvents.getAPI().getPlayerManager().getUser(this.player), "PacketEvents player was null"); - } - - @Override - public @NotNull Audience asAudience() { - return this.player; - } - - @Override - public @NotNull PermissionChecker asPermissionChecker() { - return this.player.getPermissionChecker(); - } - - @Override - public @NotNull Object unwrapNative() { - return this.player; - } -} From 7f58f35f87279c2b0cb8b9ed9c40e4d9b8df0f10 Mon Sep 17 00:00:00 2001 From: "GuavaDealer (Idan Nehama)" Date: Mon, 28 Sep 2026 02:03:41 +0300 Subject: [PATCH 2/8] refactor: migrate to coroutines and kotaml serialization - Replace SnakeYAML with Kotaml and kotlinx.serialization for type-safe configuration and storage serialization. - Integrate MCCoroutine across Paper and Velocity platforms, migrating plugin lifecycle handlers to suspending functions. - Expose a plugin-bound coroutine scope on platform interfaces and add suspending command execution builders. - Add suspending variants for storage operations and configuration reloads, offloading file I/O to Dispatchers.IO. - Implement batch marking for read changelog entries to reduce disk writes when acknowledging multiple entries. - Refactor player freeze state management from CompletableFuture to CompletableDeferred with suspending timeouts. - Update dialog editor session commits, entry deletions, and reload commands to run asynchronously in coroutines. Signed-off-by: GuavaDealer (Idan Nehama) --- build.gradle.kts | 1 + gradle/libs.versions.toml | 14 +- src/base/build.gradle.kts | 4 +- .../changelogs/base/ServerChangelogs.kt | 67 ++++---- .../base/command/AbstractCommand.kt | 41 +++++ .../base/command/ServerChangelogsCommand.kt | 21 ++- .../changelogs/base/config/PluginConfig.kt | 113 +++++++------ .../changelogs/base/data/ChangelogStorage.kt | 56 ++++++- .../base/data/storage/YamlChangelogStorage.kt | 153 ++++++++++++------ .../base/dialog/DialogSessionManager.kt | 44 +++-- .../base/dialog/ui/ChangelogDialog.kt | 13 +- .../dialog/ui/editor/ChangelogEditorDialog.kt | 31 ++-- .../base/dialog/ui/editor/EditorSession.kt | 39 +++++ .../changelogs/base/lang/TranslationSource.kt | 44 +++-- src/paper/build.gradle.kts | 2 + .../paper/PaperChangelogsPlatform.kt | 13 +- src/platformapi/build.gradle.kts | 1 + .../platformapi/ChangelogsPlatform.kt | 6 + .../changelogs/platformapi/Entrypoint.kt | 4 +- src/velocity/build.gradle.kts | 2 + .../velocity/VelocityChangelogsPlatform.kt | 17 +- 21 files changed, 493 insertions(+), 193 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index a675428..a2e269d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("org.jetbrains.kotlin.jvm") apply false id("com.gradleup.shadow") apply false + alias(libs.plugins.kotlin.serialization) apply false } group = project.findProperty("group")?.toString() ?: missingProperty("group") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 25c37e6..57cd1c9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,13 +10,23 @@ run-task = "3.0.2" shadow = "9.6.1" slf4j = "2.0.18" slf4k = "0.5.4" -snakeyaml = "2.7" velocity-api = "3.5.0-SNAPSHOT" +kotaml = "0.111.0" +mccoroutine = "2.23.0" +coroutines = "1.11.0" +kotlinx-serialization = "1.11.0" [libraries] kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } shadow-gradle-plugin = { module = "com.gradleup.shadow:shadow-gradle-plugin", version.ref = "shadow" } kotlinx-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-immutable" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } +kotaml = { module = "io.heapy.kotaml:kotaml", version.ref = "kotaml" } +mccoroutine-bukkit-api = { module = "com.github.shynixn.mccoroutine:mccoroutine-bukkit-api", version.ref = "mccoroutine" } +mccoroutine-bukkit-core = { module = "com.github.shynixn.mccoroutine:mccoroutine-bukkit-core", version.ref = "mccoroutine" } +mccoroutine-velocity-api = { module = "com.github.shynixn.mccoroutine:mccoroutine-velocity-api", version.ref = "mccoroutine" } +mccoroutine-velocity-core = { module = "com.github.shynixn.mccoroutine:mccoroutine-velocity-core", version.ref = "mccoroutine" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } adventure-api = { module = "net.kyori:adventure-api", version.ref = "adventure" } adventure-slf4j = { module = "net.kyori:adventure-text-logger-slf4j", version.ref = "adventure" } @@ -26,7 +36,6 @@ slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } slf4k = { module = "ca.solo-studios:slf4k", version.ref = "slf4k" } mojang-brigadier = { module = "com.mojang:brigadier", version.ref = "mojang-brigadier" } packetevents-api = { module = "com.github.retrooper:packetevents-api", version.ref = "packetevents" } -snakeyaml = { module = "org.yaml:snakeyaml", version.ref = "snakeyaml" } packetevents-paper = { module = "com.github.retrooper:packetevents-spigot", version.ref = "packetevents" } packetevents-velocity = { module = "com.github.retrooper:packetevents-velocity", version.ref = "packetevents" } paper-api = { module = "io.papermc.paper:paper-api", version.ref = "paper-api" } @@ -45,3 +54,4 @@ run-paper = { id = "xyz.jpenilla.run-paper", version.ref = "run-task" } run-velocity = { id = "xyz.jpenilla.run-velocity", version.ref = "run-task" } resource-factory-paper-convention = { id = "xyz.jpenilla.resource-factory-paper-convention", version.ref = "resource-factory" } resource-factory-velocity-convention = { id = "xyz.jpenilla.resource-factory-velocity-convention", version.ref = "resource-factory" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/src/base/build.gradle.kts b/src/base/build.gradle.kts index 1c1b125..4f279d0 100644 --- a/src/base/build.gradle.kts +++ b/src/base/build.gradle.kts @@ -1,11 +1,13 @@ plugins { `java-library` + alias(libs.plugins.kotlin.serialization) } dependencies { implementation(projects.platformapi) api(libs.slf4k) - compileOnly(libs.snakeyaml) + api(libs.kotaml) + api(libs.kotlinx.serialization.core) compileOnly(libs.bundles.adventure) compileOnly(libs.mojang.brigadier) compileOnly(libs.slf4j.api) diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/ServerChangelogs.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/ServerChangelogs.kt index 0f717cf..3f4a786 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/ServerChangelogs.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/ServerChangelogs.kt @@ -17,6 +17,8 @@ import com.codingcat.changelogs.platformapi.Entrypoint import com.codingcat.changelogs.platformapi.command.PlatformCommandManager import com.github.retrooper.packetevents.PacketEvents import com.github.retrooper.packetevents.PacketEventsAPI +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import net.kyori.adventure.key.Key import net.kyori.adventure.text.Component import net.kyori.adventure.text.ComponentLike @@ -25,7 +27,6 @@ import net.kyori.adventure.translation.GlobalTranslator import org.slf4j.kotlin.KLogger import org.slf4j.kotlin.info import org.slf4j.kotlin.warn -import org.yaml.snakeyaml.error.YAMLException import java.io.IOException import java.nio.file.Path import java.util.* @@ -79,20 +80,22 @@ object ServerChangelogs : Entrypoint { lateinit var logger: KLogger private set - override fun onStart() { + override suspend fun onStart() { componentLogger = platform.getComponentLogger() logger = KLogger(componentLogger) val translationPath: Path = platform.getDataPath().resolve("lang") - if (!translationPath.exists()) { - runCatching { translationPath.createDirectories() } - .onFailure { e -> logger.warn(e) { "Failed to create translation directory:" } } - logger.info { "Creating default translation files..." } - ResourceUtil.readResourcesAsString("lang").forEach { (fname, contents) -> - try { - translationPath.resolve(fname).writeText(contents) - } catch (e: IOException) { - logger.warn(e) { "Failed to create default translation file \"${fname}\":" } + withContext(Dispatchers.IO) { + if (!translationPath.exists()) { + runCatching { translationPath.createDirectories() } + .onFailure { e -> logger.warn(e) { "Failed to create translation directory:" } } + logger.info { "Creating default translation files..." } + ResourceUtil.readResourcesAsString("lang").forEach { (fname, contents) -> + try { + translationPath.resolve(fname).writeText(contents) + } catch (e: IOException) { + logger.warn(e) { "Failed to create default translation file \"${fname}\":" } + } } } } @@ -102,28 +105,30 @@ object ServerChangelogs : Entrypoint { platform.platformMeta, logger, ) - this.translationSource.reload() + this.translationSource.reloadAsync() info("console.startup") val configPath: Path = platform.getDataPath().resolve("config.yml") - if (!configPath.exists()) { - logger.info { "Creating default configuration file..." } - val defaultConfig: String = ResourceUtil.readResourceAsString("defaults/config.yml") - try { - configPath.writeText(defaultConfig) - } catch (e: IOException) { - logger.warn(e) { "Failed to create default config file \"${configPath}\":" } + withContext(Dispatchers.IO) { + if (!configPath.exists()) { + logger.info { "Creating default configuration file..." } + val defaultConfig: String = ResourceUtil.readResourceAsString("defaults/config.yml") + try { + configPath.writeText(defaultConfig) + } catch (e: IOException) { + logger.warn(e) { "Failed to create default config file \"${configPath}\":" } + } } } this.config = PluginConfig(configPath) - this.config.tryReload() + this.config.tryReloadAsync() this.changelogStorage = this.config.createChangelogStorage() info( "console.startup_storage", Component.text(this.changelogStorage.displayName), ) - this.changelogStorage.init() + this.changelogStorage.initAsync() PacketEventsFix.setManualWorkarounds(config.enabledManualWorkarounds, logger) this.dialogHolder.recreate() this.dialogSessionManager.registerEvents() @@ -143,25 +148,25 @@ object ServerChangelogs : Entrypoint { * * @param force when true, forces dialog reloading even if active dialog editors reject dismissal. * @throws IOException if configuration or translation files fail to read. - * @throws YAMLException if configuration syntax validation fails. + * @throws IllegalArgumentException if configuration syntax validation fails. * @throws PluginDialog.DestroyRejectedException if an active dialog rejects reload and [force] is false. */ - @Throws(IOException::class, YAMLException::class, PluginDialog.DestroyRejectedException::class) - fun reload(force: Boolean) { + @Throws(IOException::class, PluginDialog.DestroyRejectedException::class) + suspend fun reload(force: Boolean) { info("console.reload") if (!force) this.dialogHolder.ensureCanReload() - this.translationSource.reload() - this.config.reload() + this.translationSource.reloadAsync() + this.config.reloadAsync() this.config.validate()?.let { err -> - throw YAMLException(err) + throw IllegalArgumentException(err) } - this.changelogStorage.shutdown() + this.changelogStorage.shutdownAsync() this.changelogStorage = this.config.createChangelogStorage() info( "console.startup_storage", Component.text(this.changelogStorage.displayName), ) - this.changelogStorage.init() + this.changelogStorage.initAsync() PacketEventsFix.setManualWorkarounds(config.enabledManualWorkarounds, logger) this.dialogHolder.recreate() val eventManager = platform.eventManager @@ -169,11 +174,11 @@ object ServerChangelogs : Entrypoint { ChangelogJoinListener.registerEvents(eventManager) } - override fun onShutdown() { + override suspend fun onShutdown() { info("console.shutdown") this.dialogSessionManager.unregisterEvents() ChangelogJoinListener.unregisterEvents(platform.eventManager) - this.changelogStorage.shutdown() + this.changelogStorage.shutdownAsync() } /** diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/AbstractCommand.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/AbstractCommand.kt index f671a7a..56c83c9 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/AbstractCommand.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/AbstractCommand.kt @@ -12,6 +12,7 @@ import com.mojang.brigadier.builder.LiteralArgumentBuilder import com.mojang.brigadier.builder.RequiredArgumentBuilder import com.mojang.brigadier.context.CommandContext import com.mojang.brigadier.tree.LiteralCommandNode +import kotlinx.coroutines.launch import net.kyori.adventure.text.Component import net.kyori.adventure.text.format.NamedTextColor @@ -132,6 +133,46 @@ abstract class AbstractCommand( } } + /** + * Executes the given suspending block asynchronously only if the invoking source represents a player. + * + * @param block The suspending block invoked with the executing player and command context. + * @return The updated argument builder. + */ + protected fun > T.executesPlayerSuspend( + block: suspend (PlatformPlayer, CommandContext) -> Unit, + ): T { + return this.executes { ctx -> + val source = plugin.platform.commandManager.adaptPlatformSource(ctx.source) + val player = source.executingPlayer ?: run { + source.asAudience().sendMessage(TranslationSource.translatable("command.onlyplayer")) + return@executes Command.SINGLE_SUCCESS + } + plugin.platform.coroutineScope.launch { + block(player, ctx) + } + Command.SINGLE_SUCCESS + } + } + + /** + * Executes the given suspending block asynchronously with the adapted [PlatformCommandSource] and command context. + * + * @param block The suspending block invoked with the executing command source and command context. + * @return The updated argument builder. + */ + protected fun > T.executesSourceSuspend( + block: suspend (PlatformCommandSource, CommandContext) -> Unit, + ): T { + return this.executes { ctx -> + val source = plugin.platform.commandManager.adaptPlatformSource(ctx.source) + plugin.platform.coroutineScope.launch { + block(source, ctx) + } + Command.SINGLE_SUCCESS + } + } + /** * Attaches a permission requirement to this argument builder. * diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/ServerChangelogsCommand.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/ServerChangelogsCommand.kt index 0de7793..9890d94 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/ServerChangelogsCommand.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/ServerChangelogsCommand.kt @@ -1,5 +1,6 @@ package com.codingcat.changelogs.base.command +import com.charleskorn.kaml.YamlException import com.codingcat.changelogs.base.ServerChangelogs import com.codingcat.changelogs.base.dialog.DialogPackets import com.codingcat.changelogs.base.dialog.PluginDialog @@ -10,7 +11,6 @@ import com.codingcat.changelogs.platformapi.command.PlatformCommandSource import com.mojang.brigadier.Command import com.mojang.brigadier.builder.LiteralArgumentBuilder import net.kyori.adventure.text.Component -import org.yaml.snakeyaml.error.YAMLException import java.io.IOException /** @@ -50,9 +50,13 @@ object ServerChangelogsCommand : AbstractCommand( builder.literal("reload") { requiresPermission("command.reload") - executesSource { source, _ -> executeReload(source, false) } + executesSourceSuspend { source, _ -> + executeReload(source, false) + } literal("--force") { - executesSource { source, _ -> executeReload(source, true) } + executesSourceSuspend { source, _ -> + executeReload(source, true) + } } } @@ -64,7 +68,7 @@ object ServerChangelogsCommand : AbstractCommand( } } - private fun executeReload(source: PlatformCommandSource, force: Boolean): Int { + private suspend fun executeReload(source: PlatformCommandSource, force: Boolean): Int { val audience = source.asAudience() val time = System.currentTimeMillis() var errorMsg: Component? = null @@ -80,10 +84,15 @@ object ServerChangelogsCommand : AbstractCommand( } catch (e: IOException) { errorMsg = TranslationSource.translatable("command.reload.error.io") ServerChangelogs.error("command.reload.error.details", e) - } catch (e: YAMLException) { + } catch (e: YamlException) { + errorMsg = TranslationSource.translatable( + "command.reload.error.invalid", + Component.text(e.message), + ) + } catch (e: IllegalArgumentException) { errorMsg = TranslationSource.translatable( "command.reload.error.invalid", - Component.text(e.message ?: "Unknown YAML error"), + Component.text(e.message ?: "Invalid configuration"), ) } catch (e: PluginDialog.DestroyRejectedException) { errorMsg = TranslationSource.translatable("command.reload.error.dialog_reject.${e.key}") diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt index e6b9177..b109c5f 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt @@ -1,28 +1,55 @@ package com.codingcat.changelogs.base.config +import com.charleskorn.kaml.Yaml +import com.charleskorn.kaml.YamlConfiguration import com.codingcat.changelogs.base.ServerChangelogs import com.codingcat.changelogs.base.compat.PacketEventsFix import com.codingcat.changelogs.base.data.ChangelogStorage import com.codingcat.changelogs.base.dialog.DialogPackets import com.github.retrooper.packetevents.protocol.item.ItemStack +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import net.kyori.adventure.key.Key -import org.yaml.snakeyaml.Yaml -import org.yaml.snakeyaml.error.YAMLException -import java.io.IOException import java.nio.file.Path import java.time.ZoneId import java.time.format.DateTimeFormatter -import kotlin.io.path.inputStream +import kotlin.io.path.readText + +/** + * Serialized configuration model for plugin settings. + */ +@Serializable +data class PluginConfigData( + @SerialName("changelog_storage") + val changelogStorage: String = "yaml", + @SerialName("date_format") + val dateFormat: String = "MM/dd/yyyy", + @SerialName("date_timezone") + val dateTimezone: String = "UTC", + @SerialName("register_dedicated_command") + val registerDedicatedCommand: Boolean = true, + @SerialName("use_native_fallback_permissions") + val useNativeFallbackPermissions: Boolean = false, + @SerialName("dialog_phase") + val dialogPhase: String = "CONFIGURATION", + @SerialName("dialog_header") + val dialogHeader: Boolean = true, + @SerialName("dialog_header_item") + val dialogHeaderItem: String? = "minecraft:written_book", + @SerialName("enable_manual_workarounds") + val enableManualWorkarounds: List = emptyList(), +) /** * YAML configuration parser and validator managing plugin behavior, item stacks, and storage options. */ -@Suppress("UNCHECKED_CAST") class PluginConfig( private val path: Path, ) { - private val yaml: Yaml = Yaml() - private var data: Map = emptyMap() + private val yaml: Yaml = Yaml(configuration = YamlConfiguration(strictMode = false)) + private var data: PluginConfigData = PluginConfigData() /** * Attempts to reload and validate the configuration file, throwing [RuntimeException] on failure. @@ -34,15 +61,31 @@ class PluginConfig( throw RuntimeException("Failed to reload configuration", e) } this.validate()?.let { err -> - throw RuntimeException("Configuration invalid: $err") + throw RuntimeException("Configuration invalid: ${err}") } } - @Throws(IOException::class, YAMLException::class) + /** + * Asynchronously attempts to reload and validate configuration on Dispatchers.IO. + */ + suspend fun tryReloadAsync() = withContext(Dispatchers.IO) { + tryReload() + } + + /** + * Asynchronously reloads configuration from disk on Dispatchers.IO. + */ + suspend fun reloadAsync() = withContext(Dispatchers.IO) { + reload() + } + fun reload() { - this.data = this.path.inputStream().use { stream -> - this.yaml.load(stream) - } ?: emptyMap() + val text = this.path.readText() + this.data = if (text.isBlank()) { + PluginConfigData() + } else { + this.yaml.decodeFromString(PluginConfigData.serializer(), text) + } } fun validate(): String? = runCatching { @@ -56,20 +99,20 @@ class PluginConfig( @Throws(RuntimeException::class) fun createChangelogStorage(): ChangelogStorage { - return ChangelogStorage.create(getString("changelog_storage", "yaml")) + return ChangelogStorage.create(data.changelogStorage) } val dateFormatter: DateTimeFormatter - get() = DateTimeFormatter.ofPattern(getString("date_format", "----")) - .withZone(ZoneId.of(getString("date_timezone", "UTC"))) + get() = DateTimeFormatter.ofPattern(data.dateFormat) + .withZone(ZoneId.of(data.dateTimezone)) fun registerDedicatedCommand(): Boolean { - return getBoolean("register_dedicated_command", true) + return data.registerDedicatedCommand } val dialogPacketPhase: DialogPackets.PacketPhase get() { - val rawPhase = getString("dialog_phase", DialogPackets.PacketPhase.PLAY.name) + val rawPhase = data.dialogPhase return runCatching { DialogPackets.PacketPhase.valueOf(rawPhase.uppercase()) }.getOrElse { @@ -79,45 +122,25 @@ class PluginConfig( val enabledManualWorkarounds: Set get() { - val rawWorkarounds = getList("enable_manual_workarounds") - return rawWorkarounds.mapNotNull { workaround -> - workaround?.let { it -> - runCatching { - PacketEventsFix.Workaround.valueOf(it.uppercase()) - }.getOrElse { - throw IllegalArgumentException("Invalid manual workaround ID \"${it}\"") - } + return data.enableManualWorkarounds.map { workaround -> + runCatching { + PacketEventsFix.Workaround.valueOf(workaround.uppercase()) + }.getOrElse { + throw IllegalArgumentException("Invalid manual workaround ID \"${workaround}\"") } }.toSet() } fun showChangelogHeader(): Boolean { - return getBoolean("dialog_header", true) + return data.dialogHeader } fun useNativeFallbackPermissions(): Boolean { - return getBoolean("use_native_fallback_permissions", false) + return data.useNativeFallbackPermissions } fun createChangelogHeaderStack(): ItemStack? = - getStringOrNull("dialog_header_item")?.let(::createStack) - - private fun getString(key: String, defaultValue: String): String { - return (this.data[key] as? String) ?: defaultValue - } - - private fun getStringOrNull(key: String): String? { - return this.data[key] as? String - } - - private fun getBoolean(key: String, defaultValue: Boolean): Boolean { - return (this.data[key] as? Boolean) ?: defaultValue - } - - private inline fun getList(key: String): List { - val list = this.data[key] as? List<*> ?: return emptyList() - return list.map { it as? T } - } + data.dialogHeaderItem?.let(::createStack) private fun createStack(input: String): ItemStack { val idPart = if (input.contains("[")) input.substring(0, input.indexOf('[')) else input diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogStorage.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogStorage.kt index 620c942..57968a6 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogStorage.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogStorage.kt @@ -43,10 +43,64 @@ interface ChangelogStorage : Iterable { */ fun getByUID(uid: Int): ChangelogEntry? + /** + * Marks the entries with [uids] as acknowledged/read by [player] in batch. + */ + fun markAllAsRead(uids: Collection, player: UUID) + /** * Marks the entry with [uid] as acknowledged/read by [player]. */ - fun markAsRead(uid: Int, player: UUID) + fun markAsRead(uid: Int, player: UUID) { + markAllAsRead(listOf(uid), player) + } + + /** + * Asynchronously initializes storage structures, loading records into memory. + */ + suspend fun initAsync() { + init() + } + + /** + * Asynchronously flushes modified records and closes resources. + */ + suspend fun shutdownAsync() { + shutdown() + } + + /** + * Asynchronously persists a new changelog [entry]. + */ + suspend fun storeEntryAsync(entry: ChangelogEntry) { + storeEntry(entry) + } + + /** + * Asynchronously updates an existing changelog [entry] matching its unique ID. + */ + suspend fun updateEntryAsync(entry: ChangelogEntry) { + updateEntry(entry) + } + + /** + * Asynchronously deletes the changelog entry identified by [uid]. Returns true if an entry was removed. + */ + suspend fun removeEntryAsync(uid: Int): Boolean = removeEntry(uid) + + /** + * Asynchronously marks the entry with [uid] as acknowledged/read by [player]. + */ + suspend fun markAsReadAsync(uid: Int, player: UUID) { + markAsRead(uid, player) + } + + /** + * Asynchronously marks the entries with [uids] as acknowledged/read by [player] in batch. + */ + suspend fun markAllAsReadAsync(uids: Collection, player: UUID) { + markAllAsRead(uids, player) + } /** * Computes the next available unique identifier for a new entry. diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.kt index 7fc321d..7e729e5 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.kt @@ -1,10 +1,13 @@ package com.codingcat.changelogs.base.data.storage +import com.charleskorn.kaml.Yaml +import com.charleskorn.kaml.YamlConfiguration import com.codingcat.changelogs.base.data.ChangelogEntry import com.codingcat.changelogs.base.data.ChangelogStorage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer -import org.yaml.snakeyaml.Yaml -import org.yaml.snakeyaml.error.YAMLException import java.io.IOException import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files @@ -14,14 +17,34 @@ import java.time.Instant import java.time.format.DateTimeFormatter import java.util.* import kotlin.io.path.exists -import kotlin.io.path.inputStream -import kotlin.io.path.outputStream +import kotlin.io.path.readText +import kotlin.io.path.writeText + +/** + * Storage representation of a serialized changelog entry in Kotaml YAML. + */ +@Serializable +data class YamlStoredChangelogEntry( + val uid: Int, + val serializedLines: List = emptyList(), + val recordedAt: String, + val author: String? = null, + val playersRead: List = emptyList(), +) + +/** + * Root YAML document structure holding changelog entries. + */ +@Serializable +data class YamlChangelogDocument( + val entries: List = emptyList(), +) -@Suppress("UNCHECKED_CAST") class YamlChangelogStorage( private val filePath: Path, ) : ChangelogStorage { private val lock = Any() + private val yaml: Yaml = Yaml(configuration = YamlConfiguration(strictMode = false)) private var cache: MutableList = mutableListOf() override val displayName: String = "YAML File" @@ -34,14 +57,16 @@ class YamlChangelogStorage( return } try { - val config = Yaml() - val data = filePath.inputStream().use { stream -> - config.load>(stream) + val text = filePath.readText() + val doc = if (text.isBlank()) { + YamlChangelogDocument() + } else { + yaml.decodeFromString(YamlChangelogDocument.serializer(), text) } - this.cache = this.deserializeEntries(data ?: emptyMap()) - } catch (e: IOException) { - throw RuntimeException("Failed to load changelog data from ${filePath}", e) - } catch (e: YAMLException) { + this.cache = doc.entries.mapIndexedTo(mutableListOf()) { index, entry -> + deserializeEntry(index, entry) + } + } catch (e: Exception) { throw RuntimeException("Failed to load changelog data from ${filePath}", e) } } @@ -81,16 +106,12 @@ class YamlChangelogStorage( } private fun saveLocked() { - val config = Yaml() - val data = this.cache.map { serializeEntry(it) } + val storedEntries = this.cache.map { serializeEntry(it) } + val doc = YamlChangelogDocument(storedEntries) + val text = yaml.encodeToString(YamlChangelogDocument.serializer(), doc) val tempPath = filePath.resolveSibling("${filePath.fileName}.tmp") try { - tempPath.outputStream().use { os -> - config.dump( - mapOf("entries" to data), - os.writer().buffered(), - ) - } + tempPath.writeText(text) try { Files.move( tempPath, @@ -106,45 +127,63 @@ class YamlChangelogStorage( } } - private fun deserializeEntries(config: Map): MutableList { - return runCatching { - val entries = config["entries"] as? List> ?: emptyList() - entries.mapIndexedTo(mutableListOf()) { index, entryMap -> - deserializeEntry(index, entryMap) - } - } - .getOrElse { e -> - throw RuntimeException("Failed to deserialize changelog entries", e) - } - } - - private fun deserializeEntry(defaultIndex: Int, data: Map): ChangelogEntry { - val uid = (data["uid"] as? Number)?.toInt() ?: defaultIndex - val linesRaw = data["serializedLines"] as? List ?: emptyList() - val lines = linesRaw.map { GsonComponentSerializer.gson().deserialize(it) } - val recordedAtRaw = data["recordedAt"] as? String - ?: throw IllegalStateException("Missing recordedAt for changelog entry ${uid}") - val recordedAt = DateTimeFormatter.ISO_INSTANT.parse(recordedAtRaw, Instant::from) - val author = (data["author"] as? String)?.let { GsonComponentSerializer.gson().deserialize(it) } - val playersReadRaw = data["playersRead"] as? List ?: emptyList() - val playersRead = playersReadRaw.mapTo(mutableSetOf()) { UUID.fromString(it) } + private fun deserializeEntry(defaultIndex: Int, entry: YamlStoredChangelogEntry): ChangelogEntry { + val uid = entry.uid + val lines = entry.serializedLines.map { GsonComponentSerializer.gson().deserialize(it) } + val recordedAt = DateTimeFormatter.ISO_INSTANT.parse(entry.recordedAt, Instant::from) + val author = entry.author?.let { GsonComponentSerializer.gson().deserialize(it) } + val playersRead = entry.playersRead.mapTo(mutableSetOf()) { UUID.fromString(it) } return ChangelogEntry(uid, lines, recordedAt, author, playersRead) } - private fun serializeEntry(entry: ChangelogEntry): Map { + private fun serializeEntry(entry: ChangelogEntry): YamlStoredChangelogEntry { val serializedLines = entry.lines.map { GsonComponentSerializer.gson().serialize(it) } val recordedAtRaw = DateTimeFormatter.ISO_INSTANT.format(entry.recordedAt) val serializedAuthor = entry.author?.let { GsonComponentSerializer.gson().serialize(it) } val rawPlayersRead = entry.playersRead.map { it.toString() } - return mapOf( - "uid" to entry.uid, - "serializedLines" to serializedLines, - "recordedAt" to recordedAtRaw, - "author" to serializedAuthor, - "playersRead" to rawPlayersRead, + return YamlStoredChangelogEntry( + uid = entry.uid, + serializedLines = serializedLines, + recordedAt = recordedAtRaw, + author = serializedAuthor, + playersRead = rawPlayersRead, ) } + suspend fun saveAsync() = withContext(Dispatchers.IO) { + synchronized(lock) { + saveLocked() + } + } + + override suspend fun initAsync() = withContext(Dispatchers.IO) { + init() + } + + override suspend fun shutdownAsync() = withContext(Dispatchers.IO) { + shutdown() + } + + override suspend fun storeEntryAsync(entry: ChangelogEntry) = withContext(Dispatchers.IO) { + storeEntry(entry) + } + + override suspend fun updateEntryAsync(entry: ChangelogEntry) = withContext(Dispatchers.IO) { + updateEntry(entry) + } + + override suspend fun removeEntryAsync(uid: Int): Boolean = withContext(Dispatchers.IO) { + removeEntry(uid) + } + + override suspend fun markAsReadAsync(uid: Int, player: UUID) = withContext(Dispatchers.IO) { + markAsRead(uid, player) + } + + override suspend fun markAllAsReadAsync(uids: Collection, player: UUID) = withContext(Dispatchers.IO) { + markAllAsRead(uids, player) + } + override fun listEntries(): List { synchronized(lock) { return this.cache.toList() @@ -157,15 +196,25 @@ class YamlChangelogStorage( } } - override fun markAsRead(uid: Int, player: UUID) { + override fun markAllAsRead(uids: Collection, player: UUID) { synchronized(lock) { - val entry = this.cache.find { it.uid == uid } ?: return - if (entry.playersRead.add(player)) { + var changed = false + for (uid in uids) { + val entry = this.cache.find { it.uid == uid } ?: continue + if (entry.playersRead.add(player)) { + changed = true + } + } + if (changed) { this.saveLocked() } } } + override fun markAsRead(uid: Int, player: UUID) { + markAllAsRead(listOf(uid), player) + } + override fun nextUID(): Int { synchronized(lock) { return (this.cache.maxOfOrNull { it.uid } ?: -1) + 1 diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/DialogSessionManager.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/DialogSessionManager.kt index 3650acd..e44ef2d 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/DialogSessionManager.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/DialogSessionManager.kt @@ -18,22 +18,24 @@ import com.github.retrooper.packetevents.util.adventure.NbtTagHolder import com.github.retrooper.packetevents.wrapper.common.client.WrapperCommonClientCustomClickAction import com.github.retrooper.packetevents.wrapper.configuration.client.WrapperConfigClientCustomClickAction import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientCustomClickAction +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import net.kyori.adventure.key.Key import net.kyori.adventure.nbt.api.BinaryTagHolder import net.kyori.adventure.text.event.ClickEvent import java.util.* -import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.milliseconds /** - * PacketEvents listener coordinating dialog click actions, session state, and player freeze futures. + * PacketEvents listener coordinating dialog click actions, session state, and player freeze deferreds. */ object DialogSessionManager : PacketListener { private val staticActions: MutableSet = ConcurrentHashMap.newKeySet() private val activeSessions: MutableMap = ConcurrentHashMap() private val sessionData: MutableMap = ConcurrentHashMap() - private val frozenViewers: MutableMap> = ConcurrentHashMap() + private val frozenViewers: MutableMap> = ConcurrentHashMap() private var selfListener: PacketListenerCommon? = null fun handleCustomClick(packetWrapper: WrapperCommonClientCustomClickAction<*>, player: PlatformPlayer) { @@ -184,25 +186,41 @@ object DialogSessionManager : PacketListener { } fun isFrozen(player: PlatformPlayer): Boolean { - val future = this.frozenViewers[player.uniqueId] - return future != null && !future.isDone + val deferred = this.frozenViewers[player.uniqueId] + return deferred != null && !deferred.isCompleted } fun unfreeze(player: PlatformPlayer) { - val future = this.frozenViewers.remove(player.uniqueId) - future?.complete(null) + val deferred = this.frozenViewers.remove(player.uniqueId) + deferred?.complete(Unit) } fun freeze(player: PlatformPlayer) { - this.frozenViewers.computeIfAbsent(player.uniqueId) { CompletableFuture() } + this.frozenViewers.computeIfAbsent(player.uniqueId) { CompletableDeferred() } } - fun waitForUnfreeze(player: PlatformPlayer, timeoutSeconds: Long = 45) { - val future = this.frozenViewers[player.uniqueId] ?: return + suspend fun awaitUnfreeze(player: PlatformPlayer, timeoutSeconds: Long = 45) { + val deferred = this.frozenViewers[player.uniqueId] ?: return runCatching { - future.get(timeoutSeconds, TimeUnit.SECONDS) + withTimeout(timeoutSeconds * 1000) { + deferred.await() + } }.onFailure { - // Future timed out, was cancelled, or completed exceptionally + // Deferred timed out, was cancelled, or completed exceptionally + } + this.frozenViewers.remove(player.uniqueId) + } + + fun waitForUnfreeze(player: PlatformPlayer, timeoutSeconds: Long = 45) { + val deferred = this.frozenViewers[player.uniqueId] ?: return + runBlocking { + runCatching { + withTimeout((timeoutSeconds * 1000).milliseconds) { + deferred.await() + } + }.onFailure { + // Deferred timed out, was cancelled, or completed exceptionally + } } this.frozenViewers.remove(player.uniqueId) } diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.kt index cc0d87e..fdfd06f 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.kt @@ -1,5 +1,6 @@ package com.codingcat.changelogs.base.dialog.ui +import com.codingcat.changelogs.base.ServerChangelogs import com.codingcat.changelogs.base.compat.PacketEventsFix import com.codingcat.changelogs.base.data.ChangelogEntry import com.codingcat.changelogs.base.data.ChangelogStorage @@ -23,6 +24,7 @@ import com.github.retrooper.packetevents.protocol.dialog.button.CommonButtonData import com.github.retrooper.packetevents.protocol.item.ItemStack import com.github.retrooper.packetevents.protocol.nbt.NBTCompound import com.github.retrooper.packetevents.protocol.nbt.NBTInt +import kotlinx.coroutines.launch import net.kyori.adventure.text.Component import java.time.format.DateTimeFormatter @@ -156,8 +158,11 @@ class ChangelogDialog( when (action) { "confirm_read" -> { val unreadEntries = this.storage.listEntries().filter { !it.hasRead(source) } - unreadEntries.forEach { entry -> - storage.markAsRead(entry.uid, source.uniqueId) + if (unreadEntries.isNotEmpty()) { + val uids = unreadEntries.map { it.uid } + ServerChangelogs.platform.coroutineScope.launch { + this@ChangelogDialog.storage.markAllAsReadAsync(uids, source.uniqueId) + } } if (canManage) { sessionManager.endSession(source) @@ -207,7 +212,9 @@ class ChangelogDialog( "confirm_delete" -> { if (data == null || !canManage) return val uid = data.getNumberTagValueOrThrow("uid").toInt() - this.storage.removeEntry(uid) + ServerChangelogs.platform.coroutineScope.launch { + this@ChangelogDialog.storage.removeEntryAsync(uid) + } this.showTo(source, sessionManager, DialogPackets.PacketPhase.PLAY) } } diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.kt index 250af42..1d952e5 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.kt @@ -26,6 +26,7 @@ import com.github.retrooper.packetevents.protocol.item.type.ItemTypes import com.github.retrooper.packetevents.protocol.nbt.NBTByte import com.github.retrooper.packetevents.protocol.nbt.NBTCompound import com.github.retrooper.packetevents.protocol.nbt.NBTInt +import kotlinx.coroutines.launch import net.kyori.adventure.text.Component import net.kyori.adventure.text.Component.text import net.kyori.adventure.text.format.TextDecoration @@ -362,23 +363,25 @@ class ChangelogEditorDialog( this.showRetry(source, "no_lines", session, sessionManager) return } - try { - session.commit(this.storage) - } catch (e: EditorSession.CommitException) { - this.showRetry( + ServerChangelogs.platform.coroutineScope.launch { + try { + session.commitAsync(this@ChangelogEditorDialog.storage) + } catch (e: EditorSession.CommitException) { + this@ChangelogEditorDialog.showRetry( + source, + "${session.id}.${e.translationKeyPart}", + session, + sessionManager, + ) + return@launch + } + sessionManager.endSession(source) + DialogPackets.showSimpleNotice( source, - "${session.id}.${e.translationKeyPart}", - session, - sessionManager, + titleKey(session), + "dialog.editor.${session.id}.success", ) - return } - sessionManager.endSession(source) - DialogPackets.showSimpleNotice( - source, - titleKey(session), - "dialog.editor.${session.id}.success", - ) } } } diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.kt index 2d19bec..4a7a3bc 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.kt @@ -18,6 +18,11 @@ sealed class EditorSession( @Throws(CommitException::class) abstract fun commit(storage: ChangelogStorage) + @Throws(CommitException::class) + open suspend fun commitAsync(storage: ChangelogStorage) { + commit(storage) + } + abstract val id: String abstract val permission: String @@ -53,6 +58,22 @@ sealed class EditorSession( throw CommitException("internal_error") } } + + @Throws(CommitException::class) + override suspend fun commitAsync(storage: ChangelogStorage) { + val newEntry = ChangelogEntry( + entryUID, + this.deserializeLines(), + Instant.now(), + this.deserializeAuthor(), + mutableSetOf(), + ) + runCatching { + storage.storeEntryAsync(newEntry) + }.getOrElse { + throw CommitException("internal_error") + } + } } class Edit(entry: ChangelogEntry) : EditorSession(entry.uid) { @@ -83,6 +104,24 @@ sealed class EditorSession( throw CommitException("internal_error") } } + + @Throws(CommitException::class) + override suspend fun commitAsync(storage: ChangelogStorage) { + val currentEntry: ChangelogEntry = storage.getByUID(entryUID) + ?: throw CommitException("entry_deleted") + val newEntry = ChangelogEntry( + currentEntry.uid, + this.deserializeLines(), + currentEntry.recordedAt, + this.deserializeAuthor(), + currentEntry.playersRead, + ) + runCatching { + storage.updateEntryAsync(newEntry) + }.getOrElse { + throw CommitException("internal_error") + } + } } class CommitException(val translationKeyPart: String) : Exception() diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/lang/TranslationSource.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/lang/TranslationSource.kt index 8be3834..8a5564d 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/lang/TranslationSource.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/lang/TranslationSource.kt @@ -1,9 +1,14 @@ package com.codingcat.changelogs.base.lang +import com.charleskorn.kaml.Yaml +import com.charleskorn.kaml.YamlMap +import com.charleskorn.kaml.YamlScalar import com.codingcat.changelogs.base.ServerChangelogs import com.codingcat.changelogs.platformapi.meta.ChangelogsMeta import com.codingcat.changelogs.platformapi.meta.PlatformMeta import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import net.kyori.adventure.key.Key import net.kyori.adventure.text.Component import net.kyori.adventure.text.Component.text @@ -18,14 +23,11 @@ import net.kyori.adventure.translation.TranslationStore import org.slf4j.kotlin.KLogger import org.slf4j.kotlin.info import org.slf4j.kotlin.warn -import org.yaml.snakeyaml.Yaml -import org.yaml.snakeyaml.error.YAMLException import java.io.IOException import java.nio.file.Path import java.util.* import kotlin.io.path.* -@Suppress("UNCHECKED_CAST") class TranslationSource( private val sourceDirectory: Path, private val changelogsMeta: ChangelogsMeta, @@ -40,6 +42,13 @@ class TranslationSource( } private var translationStore: TranslationStore<*>? = null + /** + * Asynchronously reloads all translation files from disk on Dispatchers.IO. + */ + suspend fun reloadAsync() = withContext(Dispatchers.IO) { + reload() + } + fun reload() { logger.info { "Reloading translation store..." } if (!sourceDirectory.exists()) { @@ -107,18 +116,18 @@ class TranslationSource( logger.warn { "Failed to find language file ${fileName}, skipping language!" } return null } - val langFile = Yaml() return try { - val data: Map = path.inputStream().use { stream -> - langFile.load(stream) - } + val text = path.readText() + val rootNode = Yaml.default.parseToYamlNode(text) val translations = mutableMapOf() - this.addRawTranslationsRecursive(translations, "${ServerChangelogs.NAMESPACE}.", data) + if (rootNode is YamlMap) { + this.addRawTranslationsRecursive(translations, "${ServerChangelogs.NAMESPACE}.", rootNode) + } translations } catch (e: IOException) { logger.warn(e) { "Failed to load language file ${fileName} due to I/O errors:" } null - } catch (e: YAMLException) { + } catch (e: Exception) { logger.warn(e) { "Invalid syntax in language file ${fileName}:" } null } @@ -127,16 +136,19 @@ class TranslationSource( private fun addRawTranslationsRecursive( rawTranslations: MutableMap, prefix: String, - data: Map, + map: YamlMap, ) { - data.forEach { (k, v) -> - if (v is String) rawTranslations["${prefix}${k}"] = v - if (v is Map<*, *>) { - addRawTranslationsRecursive( + map.entries.forEach { (keyScalar, valueNode) -> + val key = keyScalar.content + when (valueNode) { + is YamlScalar -> rawTranslations["${prefix}${key}"] = valueNode.content + is YamlMap -> addRawTranslationsRecursive( rawTranslations, - "${prefix}${k}.", - v as Map, + "${prefix}${key}.", + valueNode, ) + + else -> {} } } } diff --git a/src/paper/build.gradle.kts b/src/paper/build.gradle.kts index 6e4b789..72a1f88 100644 --- a/src/paper/build.gradle.kts +++ b/src/paper/build.gradle.kts @@ -13,6 +13,8 @@ repositories { dependencies { implementation(projects.base) implementation(projects.platformapi) + implementation(libs.mccoroutine.bukkit.api) + implementation(libs.mccoroutine.bukkit.core) compileOnly(libs.packetevents.paper) compileOnly(libs.paper.api) testImplementation(libs.kotlin.test) diff --git a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/PaperChangelogsPlatform.kt b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/PaperChangelogsPlatform.kt index 9ab8792..f184d8a 100644 --- a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/PaperChangelogsPlatform.kt +++ b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/PaperChangelogsPlatform.kt @@ -9,14 +9,16 @@ import com.codingcat.changelogs.paper.meta.PaperPluginChangelogsMeta import com.codingcat.changelogs.paper.player.PaperPlayerManager import com.codingcat.changelogs.platformapi.ChangelogsPlatform import com.codingcat.changelogs.platformapi.Entrypoint +import com.github.shynixn.mccoroutine.bukkit.SuspendingJavaPlugin +import com.github.shynixn.mccoroutine.bukkit.scope +import kotlinx.coroutines.CoroutineScope import net.kyori.adventure.text.logger.slf4j.ComponentLogger -import org.bukkit.plugin.java.JavaPlugin import java.nio.file.Path /** * Paper platform adapter and plugin implementation for ServerChangelogs. */ -class PaperChangelogsPlatform : JavaPlugin(), ChangelogsPlatform { +class PaperChangelogsPlatform : SuspendingJavaPlugin(), ChangelogsPlatform { companion object { lateinit var instance: PaperChangelogsPlatform private set @@ -38,6 +40,9 @@ class PaperChangelogsPlatform : JavaPlugin(), ChangelogsPlatform { override fun getDataPath(): Path = super.getDataPath() + override val coroutineScope: CoroutineScope + get() = this.scope + override val playerManager: PaperPlayerManager = PaperPlayerManager override val eventManager: PaperEventManager = PaperEventManager @@ -46,7 +51,7 @@ class PaperChangelogsPlatform : JavaPlugin(), ChangelogsPlatform { private var earlyInitSucceeded = false - override fun onEnable() { + override suspend fun onEnableAsync() { runCatching { Class.forName("com.github.retrooper.packetevents.PacketEvents") }.onFailure { @@ -67,7 +72,7 @@ class PaperChangelogsPlatform : JavaPlugin(), ChangelogsPlatform { commandManager.registerEvents(lifecycleManager) } - override fun onDisable() { + override suspend fun onDisableAsync() { if (!earlyInitSucceeded) return try { runCatching { diff --git a/src/platformapi/build.gradle.kts b/src/platformapi/build.gradle.kts index ed9312f..6bc94df 100644 --- a/src/platformapi/build.gradle.kts +++ b/src/platformapi/build.gradle.kts @@ -4,6 +4,7 @@ plugins { dependencies { api(libs.kotlinx.immutable) + api(libs.kotlinx.coroutines.core) compileOnly(libs.bundles.adventure) compileOnly(libs.packetevents.api) compileOnly(libs.mojang.brigadier) diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/ChangelogsPlatform.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/ChangelogsPlatform.kt index c7d0911..0fef94b 100644 --- a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/ChangelogsPlatform.kt +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/ChangelogsPlatform.kt @@ -6,6 +6,7 @@ import com.codingcat.changelogs.platformapi.item.NativeItemManager import com.codingcat.changelogs.platformapi.meta.ChangelogsMeta import com.codingcat.changelogs.platformapi.meta.PlatformMeta import com.codingcat.changelogs.platformapi.player.PlatformPlayerManager +import kotlinx.coroutines.CoroutineScope import net.kyori.adventure.text.logger.slf4j.ComponentLogger import java.nio.file.Path @@ -25,6 +26,11 @@ interface ChangelogsPlatform { */ fun getDataPath(): Path + /** + * Coroutine scope bound to the platform plugin lifecycle. + */ + val coroutineScope: CoroutineScope + /** * Manager handling player lookups and adapting native player instances to platform representations. */ diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/Entrypoint.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/Entrypoint.kt index cd1c3af..6e9db10 100644 --- a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/Entrypoint.kt +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/Entrypoint.kt @@ -19,10 +19,10 @@ interface Entrypoint { /** * Initializes configurations, storage backends, and command/event registrations on startup. */ - fun onStart() + suspend fun onStart() /** * Shuts down storage, flushes pending state, and releases registered resources. */ - fun onShutdown() + suspend fun onShutdown() } diff --git a/src/velocity/build.gradle.kts b/src/velocity/build.gradle.kts index 0478086..eda4619 100644 --- a/src/velocity/build.gradle.kts +++ b/src/velocity/build.gradle.kts @@ -15,6 +15,8 @@ repositories { dependencies { implementation(projects.base) implementation(projects.platformapi) + implementation(libs.mccoroutine.velocity.api) + implementation(libs.mccoroutine.velocity.core) compileOnly(libs.packetevents.velocity) compileOnly(libs.velocity.api) testImplementation(libs.kotlin.test) diff --git a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/VelocityChangelogsPlatform.kt b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/VelocityChangelogsPlatform.kt index 6d9b0e7..07e030b 100644 --- a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/VelocityChangelogsPlatform.kt +++ b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/VelocityChangelogsPlatform.kt @@ -11,6 +11,8 @@ import com.codingcat.changelogs.velocity.meta.VelocityPluginChangelogsMeta import com.codingcat.changelogs.velocity.player.VelocityPlayerManager import com.github.retrooper.packetevents.PacketEvents import com.github.retrooper.packetevents.PacketEventsAPI +import com.github.shynixn.mccoroutine.velocity.SuspendingPluginContainer +import com.github.shynixn.mccoroutine.velocity.scope import com.google.inject.Inject import com.velocitypowered.api.event.Subscribe import com.velocitypowered.api.event.proxy.ProxyInitializeEvent @@ -18,6 +20,7 @@ import com.velocitypowered.api.event.proxy.ProxyPreShutdownEvent import com.velocitypowered.api.plugin.PluginContainer import com.velocitypowered.api.plugin.annotation.DataDirectory import com.velocitypowered.api.proxy.ProxyServer +import kotlinx.coroutines.CoroutineScope import net.kyori.adventure.text.logger.slf4j.ComponentLogger import java.nio.file.Path @@ -28,14 +31,22 @@ class VelocityChangelogsPlatform @Inject constructor( server: ProxyServer, private val componentLogger: ComponentLogger, @DataDirectory private val dataDirectory: Path, - pluginContainer: PluginContainer, + private val pluginContainer: PluginContainer, + suspendingPluginContainer: SuspendingPluginContainer, ) : ChangelogsPlatform { + init { + suspendingPluginContainer.initialize(this) + } + private val entrypoint: Entrypoint = Entrypoints.create(this) override fun getComponentLogger(): ComponentLogger = componentLogger override fun getDataPath(): Path = dataDirectory + override val coroutineScope: CoroutineScope + get() = pluginContainer.scope + override val platformMeta: VelocityPlatformMeta = VelocityPlatformMeta(server.version) override val changelogsMeta: VelocityPluginChangelogsMeta = @@ -62,7 +73,7 @@ class VelocityChangelogsPlatform @Inject constructor( private var earlyInitSucceeded = false @Subscribe - fun onProxyInitialization(event: ProxyInitializeEvent) { + suspend fun onProxyInitialization(event: ProxyInitializeEvent) { runCatching { Class.forName("com.github.retrooper.packetevents.PacketEvents") }.onFailure { @@ -83,7 +94,7 @@ class VelocityChangelogsPlatform @Inject constructor( @Subscribe @Suppress("UnstableApiUsage") - fun onProxyPreShutdown(event: ProxyPreShutdownEvent) { + suspend fun onProxyPreShutdown(event: ProxyPreShutdownEvent) { if (!earlyInitSucceeded) return try { runCatching { From fdbfb4873fc1e19c727139ccba7b8de61e148113 Mon Sep 17 00:00:00 2001 From: "GuavaDealer (Idan Nehama)" Date: Mon, 28 Sep 2026 02:31:27 +0300 Subject: [PATCH 3/8] feat(storage): add relational database support via JetBrains Exposed - Implement relational database storage backend powered by JetBrains Exposed with support for SQLite, MySQL, MariaDB, PostgreSQL, and H2. - Define dedicated relational tables for changelog entries and player-read receipts with cascading foreign key references. - Support automatic migration of existing _data.yml changelog records into the database upon startup with backup preservation. - Introduce database connection settings and credentials configuration with optional custom JDBC URL overrides in config.yml. - Add synchronous and asynchronous configuration saving methods via Kotaml serialization. - Update storage factory instantiation to resolve database engine identifiers and connect with configured credentials. - Add JetBrains Exposed and SQLite JDBC dependencies to Gradle version catalog and build configurations. - Add unit tests verifying database schema creation, entry operations, read tracking, and configuration parsing. - Update project roadmap documentation to reflect completed JDBC database support. Signed-off-by: GuavaDealer (Idan Nehama) --- README.md | 2 +- build.gradle.kts | 11 +- gradle/libs.versions.toml | 13 + src/base/build.gradle.kts | 3 + .../changelogs/base/config/PluginConfig.kt | 52 ++- .../changelogs/base/data/ChangelogStorage.kt | 35 ++ .../data/storage/ExposedChangelogStorage.kt | 330 ++++++++++++++++++ .../src/main/resources/defaults/config.yml | 25 +- .../base/config/PluginConfigTest.kt | 54 +++ .../storage/ExposedChangelogStorageTest.kt | 127 +++++++ 10 files changed, 642 insertions(+), 10 deletions(-) create mode 100644 src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/ExposedChangelogStorage.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/config/PluginConfigTest.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/data/storage/ExposedChangelogStorageTest.kt diff --git a/README.md b/README.md index 7c08aa9..cb38f20 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ ServerChangelogs was originally made for a **smaller minecraft server** I'm a pa The roadmap below contains some of the features that I'm **planning to add** in the future: -- [ ] **JDBC (relational) database support**: +- [x] **JDBC (relational) database support**: Currently, the plugin stores all changelog data (including who has already seen what changelogs) in a plain YAML file. The reason for this mostly comes down to the stuff above (we wanted a solution fast), so we didn't prioritize storage. This does start to cause problems over time, since the amount of data that has to be (de-)serialized for each startup/write operation increases by a lot! diff --git a/build.gradle.kts b/build.gradle.kts index a2e269d..a7e01e9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -41,11 +41,7 @@ subprojects { archiveFileName = "${rootProject.name}-${project.name}-${project.version}.jar" destinationDirectory.set(rootProject.layout.projectDirectory.dir("final")) mustRunAfter(cleanFinalArtifacts) - dependencies { - exclude(dependency("org.slf4j:slf4j-api")) - } - relocate("kotlinx.collections.immutable", "com.codingcat.changelogs.libs.immutable") - relocate("ca.solostudios.slf4k", "com.codingcat.changelogs.libs.slf4k") + duplicatesStrategy = DuplicatesStrategy.INCLUDE } repositories { @@ -60,6 +56,11 @@ subprojects { dependsOn(tasks.named("shadowJar")) } } + + tasks.withType().configureEach { + outputs.upToDateWhen { false } + useJUnitPlatform() + } } allprojects { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 57cd1c9..4d7a186 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,8 @@ kotaml = "0.111.0" mccoroutine = "2.23.0" coroutines = "1.11.0" kotlinx-serialization = "1.11.0" +exposed = "1.5.0" +sqlite-jdbc = "3.53.4.0" [libraries] kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } @@ -40,6 +42,11 @@ packetevents-paper = { module = "com.github.retrooper:packetevents-spigot", vers packetevents-velocity = { module = "com.github.retrooper:packetevents-velocity", version.ref = "packetevents" } paper-api = { module = "io.papermc.paper:paper-api", version.ref = "paper-api" } velocity-api = { module = "com.velocitypowered:velocity-api", version.ref = "velocity-api" } +exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" } +exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" } +exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" } +exposed-java-time = { module = "org.jetbrains.exposed:exposed-java-time", version.ref = "exposed" } +sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite-jdbc" } [bundles] adventure = [ @@ -48,6 +55,12 @@ adventure = [ "adventure-minimessage", "adventure-gson", ] +exposed = [ + "exposed-core", + "exposed-jdbc", + "exposed-dao", + "exposed-java-time", +] [plugins] run-paper = { id = "xyz.jpenilla.run-paper", version.ref = "run-task" } diff --git a/src/base/build.gradle.kts b/src/base/build.gradle.kts index 4f279d0..7ea3ce0 100644 --- a/src/base/build.gradle.kts +++ b/src/base/build.gradle.kts @@ -8,9 +8,12 @@ dependencies { api(libs.slf4k) api(libs.kotaml) api(libs.kotlinx.serialization.core) + api(libs.bundles.exposed) + api(libs.sqlite.jdbc) compileOnly(libs.bundles.adventure) compileOnly(libs.mojang.brigadier) compileOnly(libs.slf4j.api) compileOnly(libs.packetevents.api) testImplementation(libs.kotlin.test) + testImplementation(libs.bundles.adventure) } diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt index b109c5f..08ec31a 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt @@ -16,6 +16,24 @@ import java.nio.file.Path import java.time.ZoneId import java.time.format.DateTimeFormatter import kotlin.io.path.readText +import kotlin.io.path.writeText + +/** + * Configuration settings for relational database backends via JetBrains Exposed. + */ +@Serializable +data class DatabaseConfig( + val type: String = "sqlite", + val host: String = "localhost", + val port: Int = 3306, + val database: String = "server_changelogs", + val username: String = "root", + val password: String = "", + @SerialName("jdbc_url") + val jdbcUrl: String? = null, + @SerialName("auto_migrate_yaml") + val autoMigrateYaml: Boolean = true, +) /** * Serialized configuration model for plugin settings. @@ -24,6 +42,7 @@ import kotlin.io.path.readText data class PluginConfigData( @SerialName("changelog_storage") val changelogStorage: String = "yaml", + val database: DatabaseConfig = DatabaseConfig(), @SerialName("date_format") val dateFormat: String = "MM/dd/yyyy", @SerialName("date_timezone") @@ -88,6 +107,21 @@ class PluginConfig( } } + /** + * Serializes and writes configuration state to disk using Kotaml. + */ + fun save() { + val text = this.yaml.encodeToString(PluginConfigData.serializer(), this.data) + this.path.writeText(text) + } + + /** + * Asynchronously serializes and writes configuration to disk using Kotaml on Dispatchers.IO. + */ + suspend fun saveAsync() = withContext(Dispatchers.IO) { + save() + } + fun validate(): String? = runCatching { this.dialogPacketPhase createChangelogStorage() @@ -98,10 +132,24 @@ class PluginConfig( }.getOrElse { it.message } @Throws(RuntimeException::class) - fun createChangelogStorage(): ChangelogStorage { - return ChangelogStorage.create(data.changelogStorage) + fun createChangelogStorage(dataPath: Path? = null): ChangelogStorage { + val root = dataPath ?: runCatching { ServerChangelogs.platform.getDataPath() } + .getOrElse { this.path.parent ?: Path.of(".") } + return ChangelogStorage.create(this, root) } + /** + * Active database configuration settings. + */ + val databaseConfig: DatabaseConfig + get() = data.database + + /** + * Configured changelog storage identifier (e.g., "yaml", "sqlite", "mysql"). + */ + val changelogStorageType: String + get() = data.changelogStorage + val dateFormatter: DateTimeFormatter get() = DateTimeFormatter.ofPattern(data.dateFormat) .withZone(ZoneId.of(data.dateTimezone)) diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogStorage.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogStorage.kt index 57968a6..e470612 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogStorage.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogStorage.kt @@ -1,6 +1,8 @@ package com.codingcat.changelogs.base.data import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.base.config.PluginConfig +import com.codingcat.changelogs.base.data.storage.ExposedChangelogStorage import com.codingcat.changelogs.base.data.storage.YamlChangelogStorage import java.util.* @@ -132,6 +134,39 @@ interface ChangelogStorage : Iterable { } companion object { + /** + * Resolves and instantiates a [ChangelogStorage] backend for the requested [config]. + * + * @throws IllegalArgumentException if the storage type identifier is unsupported. + */ + @Throws(IllegalArgumentException::class) + fun create( + config: PluginConfig, + dataPath: java.nio.file.Path = runCatching { ServerChangelogs.platform.getDataPath() } + .getOrElse { java.nio.file.Path.of(".") }, + ): ChangelogStorage { + return when (val identifier = config.changelogStorageType.lowercase()) { + "yaml" -> YamlChangelogStorage(dataPath.resolve("_data.yml")) + "sqlite", "h2", "mysql", "mariadb", "postgresql", "postgres", "exposed", "jdbc" -> { + val logger = runCatching { ServerChangelogs.logger } + .getOrElse { org.slf4j.kotlin.KLogger(org.slf4j.LoggerFactory.getLogger("ChangelogsStorage")) } + ExposedChangelogStorage( + dataPath = dataPath, + config = config.databaseConfig.copy( + type = if (identifier in setOf("exposed", "jdbc")) { + config.databaseConfig.type + } else { + identifier + }, + ), + logger = logger, + ) + } + + else -> throw IllegalArgumentException("Unknown changelog storage type \"${identifier}\"") + } + } + /** * Resolves and instantiates a [ChangelogStorage] backend for the requested [identifier]. * diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/ExposedChangelogStorage.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/ExposedChangelogStorage.kt new file mode 100644 index 0000000..5024fe1 --- /dev/null +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/ExposedChangelogStorage.kt @@ -0,0 +1,330 @@ +package com.codingcat.changelogs.base.data.storage + +import com.codingcat.changelogs.base.config.DatabaseConfig +import com.codingcat.changelogs.base.data.ChangelogEntry +import com.codingcat.changelogs.base.data.ChangelogStorage +import com.google.gson.Gson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer +import org.jetbrains.exposed.v1.core.ReferenceOption +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.javatime.timestamp +import org.jetbrains.exposed.v1.jdbc.* +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.slf4j.kotlin.KLogger +import org.slf4j.kotlin.info +import java.nio.file.Path +import java.time.Instant +import java.util.* +import kotlin.io.path.exists +import kotlin.io.path.moveTo + +/** + * Exposed database table representing stored changelog entries. + */ +object ChangelogEntriesTable : Table("server_changelogs_entries") { + val uid = integer("uid") + val lines = text("lines") + val recordedAt = timestamp("recorded_at") + val author = text("author").nullable() + + override val primaryKey = PrimaryKey(uid) +} + +/** + * Exposed database table tracking player read receipts per changelog entry. + */ +object ChangelogReadsTable : Table("server_changelogs_reads") { + val changelogUid = integer("changelog_uid").references( + ChangelogEntriesTable.uid, + onDelete = ReferenceOption.CASCADE, + ) + val playerUuid = varchar("player_uuid", 36) + + override val primaryKey = PrimaryKey(changelogUid, playerUuid) +} + +/** + * Relational database storage engine powered by JetBrains Exposed. + */ +class ExposedChangelogStorage( + private val dataPath: Path, + private val config: DatabaseConfig, + private val logger: KLogger, +) : ChangelogStorage { + private val lock = Any() + private val gson = Gson() + private var cache: MutableList = mutableListOf() + private lateinit var database: Database + + override val displayName: String = "Exposed Database (${config.type})" + + override fun init() { + this.database = createDatabase() + transaction(this.database) { + SchemaUtils.create(ChangelogEntriesTable, ChangelogReadsTable) + } + if (config.autoMigrateYaml) { + checkAutoMigrate() + } + synchronized(lock) { + loadFromDatabaseLocked() + } + } + + override fun shutdown() { + } + + override suspend fun initAsync() = withContext(Dispatchers.IO) { + init() + } + + override suspend fun shutdownAsync() = withContext(Dispatchers.IO) { + shutdown() + } + + override fun storeEntry(entry: ChangelogEntry) { + synchronized(lock) { + this.cache.removeIf { it.uid == entry.uid } + this.cache.add(entry) + } + saveEntryToDb(entry) + } + + override fun updateEntry(entry: ChangelogEntry) { + synchronized(lock) { + val idx = this.cache.indexOfFirst { it.uid == entry.uid } + if (idx >= 0) { + this.cache[idx] = entry + } else { + this.cache.add(entry) + } + } + saveEntryToDb(entry) + } + + override fun removeEntry(uid: Int): Boolean { + val removed: Boolean + synchronized(lock) { + removed = this.cache.removeIf { it.uid == uid } + } + if (removed) { + transaction(this.database) { + ChangelogEntriesTable.deleteWhere { ChangelogEntriesTable.uid eq uid } + } + } + return removed + } + + override suspend fun storeEntryAsync(entry: ChangelogEntry) = withContext(Dispatchers.IO) { + storeEntry(entry) + } + + override suspend fun updateEntryAsync(entry: ChangelogEntry) = withContext(Dispatchers.IO) { + updateEntry(entry) + } + + override suspend fun removeEntryAsync(uid: Int): Boolean = withContext(Dispatchers.IO) { + removeEntry(uid) + } + + override fun listEntries(): List { + synchronized(lock) { + return this.cache.toList() + } + } + + override fun getByUID(uid: Int): ChangelogEntry? { + synchronized(lock) { + return this.cache.find { it.uid == uid } + } + } + + override fun markAllAsRead(uids: Collection, player: UUID) { + val toInsert = mutableListOf() + synchronized(lock) { + for (uid in uids) { + val entry = this.cache.find { it.uid == uid } ?: continue + if (entry.playersRead.add(player)) { + toInsert.add(uid) + } + } + } + if (toInsert.isNotEmpty()) { + val playerStr = player.toString() + transaction(this.database) { + ChangelogReadsTable.batchInsert(toInsert, ignore = true) { uid -> + this[ChangelogReadsTable.changelogUid] = uid + this[ChangelogReadsTable.playerUuid] = playerStr + } + } + } + } + + override fun markAsRead(uid: Int, player: UUID) { + markAllAsRead(listOf(uid), player) + } + + override suspend fun markAsReadAsync(uid: Int, player: UUID) = withContext(Dispatchers.IO) { + markAsRead(uid, player) + } + + override suspend fun markAllAsReadAsync(uids: Collection, player: UUID) = withContext(Dispatchers.IO) { + markAllAsRead(uids, player) + } + + override fun nextUID(): Int { + synchronized(lock) { + return (this.cache.maxOfOrNull { it.uid } ?: -1) + 1 + } + } + + private fun saveEntryToDb(entry: ChangelogEntry) { + val serializedLines = entry.lines.map { GsonComponentSerializer.gson().serialize(it) } + val jsonLines = gson.toJson(serializedLines) + val serializedAuthor = entry.author?.let { GsonComponentSerializer.gson().serialize(it) } + val playerStrings = entry.playersRead.map { it.toString() } + + transaction(this.database) { + ChangelogEntriesTable.deleteWhere { uid eq entry.uid } + ChangelogEntriesTable.insert { row -> + row[uid] = entry.uid + row[lines] = jsonLines + row[recordedAt] = entry.recordedAt + row[author] = serializedAuthor + } + if (playerStrings.isNotEmpty()) { + ChangelogReadsTable.batchInsert(playerStrings, ignore = true) { pStr -> + this[ChangelogReadsTable.changelogUid] = entry.uid + this[ChangelogReadsTable.playerUuid] = pStr + } + } + } + } + + private fun loadFromDatabaseLocked() { + val entries = transaction(this.database) { + val readsMap = mutableMapOf>() + ChangelogReadsTable.selectAll().forEach { row -> + val cUid = row[ChangelogReadsTable.changelogUid] + val pUuid = runCatching { UUID.fromString(row[ChangelogReadsTable.playerUuid]) }.getOrNull() + if (pUuid != null) { + readsMap.computeIfAbsent(cUid) { mutableSetOf() }.add(pUuid) + } + } + + ChangelogEntriesTable.selectAll().map { row -> + val uid = row[ChangelogEntriesTable.uid] + val linesRaw: List = runCatching { + gson.fromJson(row[ChangelogEntriesTable.lines], Array::class.java)?.toList() + }.getOrNull() ?: emptyList() + val lines: List = linesRaw.map { GsonComponentSerializer.gson().deserialize(it) } + val recordedAt: Instant = row[ChangelogEntriesTable.recordedAt] + val author: Component? = row[ChangelogEntriesTable.author]?.let { + GsonComponentSerializer.gson().deserialize(it) + } + val playersRead: MutableSet = readsMap[uid] ?: mutableSetOf() + ChangelogEntry(uid, lines, recordedAt, author, playersRead) + } + } + this.cache = entries.sortedBy { it.uid }.toMutableList() + } + + private fun checkAutoMigrate() { + val yamlFile = dataPath.resolve("_data.yml") + if (!yamlFile.exists()) return + + val isEmpty = transaction(this.database) { + ChangelogEntriesTable.selectAll().empty() + } + if (!isEmpty) return + + logger.info { "Found existing _data.yml with empty database, starting auto-migration..." } + val yamlStorage = YamlChangelogStorage(yamlFile) + yamlStorage.init() + val yamlEntries = yamlStorage.listEntries() + if (yamlEntries.isEmpty()) return + + transaction(this.database) { + for ((uid, lines, recordedAt, author, playersRead) in yamlEntries) { + val serializedLines = lines.map { GsonComponentSerializer.gson().serialize(it) } + val jsonLines = gson.toJson(serializedLines) + val serializedAuthor = author?.let { GsonComponentSerializer.gson().serialize(it) } + ChangelogEntriesTable.insert { row -> + row[this@insert.uid] = uid + row[this@insert.lines] = jsonLines + row[this@insert.recordedAt] = recordedAt + row[this@insert.author] = serializedAuthor + } + if (playersRead.isNotEmpty()) { + val pStrings = playersRead.map { it.toString() } + ChangelogReadsTable.batchInsert(pStrings, ignore = true) { pStr -> + this[ChangelogReadsTable.changelogUid] = uid + this[ChangelogReadsTable.playerUuid] = pStr + } + } + } + } + logger.info { "Successfully migrated ${yamlEntries.size} entries from _data.yml to Exposed database." } + val backupFile = dataPath.resolve("_data.yml.bak") + runCatching { yamlFile.moveTo(backupFile, overwrite = true) } + } + + private fun createDatabase(): Database { + val jdbcUrl = config.jdbcUrl ?: when (config.type.lowercase()) { + "sqlite" -> { + val dbFile = dataPath.resolve("changelogs.db").toAbsolutePath() + "jdbc:sqlite:${dbFile}" + } + + "h2" -> { + val dbFile = dataPath.resolve("changelogs").toAbsolutePath() + "jdbc:h2:${dbFile};DB_CLOSE_DELAY=-1" + } + + "mysql" -> { + "jdbc:mysql://${config.host}:${config.port}/${config.database}?useSSL=false&allowPublicKeyRetrieval=true" + } + + "mariadb" -> { + "jdbc:mariadb://${config.host}:${config.port}/${config.database}?useSSL=false&allowPublicKeyRetrieval=true" + } + + "postgresql", "postgres" -> { + "jdbc:postgresql://${config.host}:${config.port}/${config.database}" + } + + else -> { + val dbFile = dataPath.resolve("changelogs.db").toAbsolutePath() + "jdbc:sqlite:${dbFile}" + } + } + + val driver = when { + jdbcUrl.startsWith("jdbc:sqlite:") -> "org.sqlite.JDBC" + jdbcUrl.startsWith("jdbc:h2:") -> "org.h2.Driver" + jdbcUrl.startsWith("jdbc:mysql:") -> "com.mysql.cj.jdbc.Driver" + jdbcUrl.startsWith("jdbc:mariadb:") -> "org.mariadb.jdbc.Driver" + jdbcUrl.startsWith("jdbc:postgresql:") -> "org.postgresql.Driver" + else -> null + } + + return if (driver != null) { + Database.connect( + url = jdbcUrl, + driver = driver, + user = config.username, + password = config.password, + ) + } else { + Database.connect( + url = jdbcUrl, + user = config.username, + password = config.password, + ) + } + } +} diff --git a/src/base/src/main/resources/defaults/config.yml b/src/base/src/main/resources/defaults/config.yml index f7f60ba..9aa4fd4 100644 --- a/src/base/src/main/resources/defaults/config.yml +++ b/src/base/src/main/resources/defaults/config.yml @@ -6,10 +6,31 @@ # # The changelog storage is used to store all server changelogs. -# Currently, the only option available is "yaml", which stores changelogs -# in a human-readable YAML configuration file. +# Available options: +# - "yaml": Stores changelogs in a plain YAML configuration file (_data.yml). +# - "sqlite": Stores changelogs in a local SQLite database file via JetBrains Exposed. +# - "mysql", "mariadb", "postgresql", "h2": Connects to a relational database via JetBrains Exposed. +# - "exposed" / "jdbc": Generic Exposed database storage configured below. changelog_storage: yaml +# Database connection settings when using JetBrains Exposed (sqlite, mysql, mariadb, postgresql, h2, exposed). +database: + # Database engine: sqlite, mysql, mariadb, postgresql, h2 + type: sqlite + # Hostname for remote databases + host: "localhost" + # Port for remote databases (3306 for MySQL/MariaDB, 5432 for PostgreSQL) + port: 3306 + # Database or schema name + database: "server_changelogs" + # Authentication credentials + username: "root" + password: "" + # Optional explicit JDBC connection URL (overrides host, port, database if set) + # jdbc_url: "jdbc:sqlite:changelogs.db" + # Automatically migrate existing entries from _data.yml into database on startup if database is empty: + auto_migrate_yaml: true + # The format used to display the date/time of a given changelog. # yyyy = year (with 4 digits) # dd = day of the month (2 digits) diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/config/PluginConfigTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/config/PluginConfigTest.kt new file mode 100644 index 0000000..efd4941 --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/config/PluginConfigTest.kt @@ -0,0 +1,54 @@ +package com.codingcat.changelogs.base.config + +import java.nio.file.Files +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PluginConfigTest { + @Test + fun testDefaultConfigLoadAndSaveWithKotaml() { + val tempFile = Files.createTempFile("test_config", ".yml") + val sampleYaml = """ + changelog_storage: sqlite + database: + type: sqlite + host: "127.0.0.1" + port: 3306 + database: "changelogs_test" + auto_migrate_yaml: true + date_format: "yyyy-MM-dd" + date_timezone: "UTC" + register_dedicated_command: true + use_native_fallback_permissions: false + dialog_phase: PLAY + dialog_header: false + dialog_header_item: null + enable_manual_workarounds: [] + """.trimIndent() + + tempFile.writeText(sampleYaml) + + val config = PluginConfig(tempFile) + config.tryReload() + + assertEquals("sqlite", config.changelogStorageType) + assertEquals("sqlite", config.databaseConfig.type) + assertEquals("127.0.0.1", config.databaseConfig.host) + assertEquals(3306, config.databaseConfig.port) + assertTrue(config.databaseConfig.autoMigrateYaml) + assertFalse(config.showChangelogHeader()) + + // Test saving back with Kotaml + config.save() + val reloaded = PluginConfig(tempFile) + reloaded.tryReload() + + assertEquals("sqlite", reloaded.changelogStorageType) + assertEquals("sqlite", reloaded.databaseConfig.type) + + Files.deleteIfExists(tempFile) + } +} diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/data/storage/ExposedChangelogStorageTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/data/storage/ExposedChangelogStorageTest.kt new file mode 100644 index 0000000..cf7bec9 --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/data/storage/ExposedChangelogStorageTest.kt @@ -0,0 +1,127 @@ +package com.codingcat.changelogs.base.data.storage + +import com.codingcat.changelogs.base.config.DatabaseConfig +import com.codingcat.changelogs.base.data.ChangelogEntry +import net.kyori.adventure.text.Component +import org.slf4j.LoggerFactory +import org.slf4j.kotlin.KLogger +import java.nio.file.Files +import java.time.Instant +import java.util.* +import kotlin.io.path.exists +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ExposedChangelogStorageTest { + private val logger = KLogger(LoggerFactory.getLogger("TestLogger")) + + @Test + fun testStoreAndRetrieveEntries() { + val tempDir = Files.createTempDirectory("changelogs_exposed_test") + val config = DatabaseConfig(type = "sqlite") + val storage = ExposedChangelogStorage(tempDir, config, logger) + + storage.init() + + val playerUuid = UUID.randomUUID() + val entry = ChangelogEntry( + uid = 1, + lines = listOf(Component.text("Line 1"), Component.text("Line 2")), + recordedAt = Instant.now(), + author = Component.text("Admin"), + playersRead = mutableSetOf(), + ) + + storage.storeEntry(entry) + assertEquals(1, storage.listEntries().size) + assertEquals(1, storage.getByUID(1)?.uid) + + storage.markAsRead(1, playerUuid) + val updated = storage.getByUID(1) + assertNotNull(updated) + assertTrue(updated.playersRead.contains(playerUuid)) + + // Create new storage instance pointing to same DB file to ensure persistence + val reloadedStorage = ExposedChangelogStorage(tempDir, config, logger) + reloadedStorage.init() + assertEquals(1, reloadedStorage.listEntries().size) + val reloadedEntry = reloadedStorage.getByUID(1) + assertNotNull(reloadedEntry) + assertTrue(reloadedEntry.playersRead.contains(playerUuid)) + + // Test delete + assertTrue(reloadedStorage.removeEntry(1)) + assertEquals(0, reloadedStorage.listEntries().size) + + tempDir.toFile().deleteRecursively() + } + + @Test + fun testAutoMigrationFromYaml() { + val tempDir = Files.createTempDirectory("changelogs_migration_test") + val yamlFile = tempDir.resolve("_data.yml") + val yamlStorage = YamlChangelogStorage(yamlFile) + yamlStorage.init() + + val playerUuid = UUID.randomUUID() + val entry = ChangelogEntry( + uid = 42, + lines = listOf(Component.text("Migrated Line")), + recordedAt = Instant.now(), + author = Component.text("Migrator"), + playersRead = mutableSetOf(playerUuid), + ) + yamlStorage.storeEntry(entry) + assertTrue(yamlFile.exists()) + + // Initialize Exposed storage with autoMigrateYaml = true + val config = DatabaseConfig(type = "sqlite", autoMigrateYaml = true) + val exposedStorage = ExposedChangelogStorage(tempDir, config, logger) + exposedStorage.init() + + // Verify entry migrated + val migrated = exposedStorage.getByUID(42) + assertNotNull(migrated) + assertEquals("Migrator", (migrated.author as? net.kyori.adventure.text.TextComponent)?.content()) + assertTrue(migrated.playersRead.contains(playerUuid)) + assertTrue(tempDir.resolve("_data.yml.bak").exists()) + + tempDir.toFile().deleteRecursively() + } + + @Test + fun testAsyncOperations() { + kotlinx.coroutines.runBlocking { + val tempDir = Files.createTempDirectory("changelogs_async_test") + val config = DatabaseConfig(type = "sqlite") + val storage = ExposedChangelogStorage(tempDir, config, logger) + + storage.initAsync() + + val playerUuid = UUID.randomUUID() + val entry = ChangelogEntry( + uid = 10, + lines = listOf(Component.text("Async Line")), + recordedAt = Instant.now(), + author = Component.text("AsyncAuthor"), + playersRead = mutableSetOf(), + ) + + storage.storeEntryAsync(entry) + assertEquals(1, storage.listEntries().size) + + storage.markAllAsReadAsync(listOf(10), playerUuid) + val readEntry = storage.getByUID(10) + assertNotNull(readEntry) + assertTrue(readEntry.playersRead.contains(playerUuid)) + + assertTrue(storage.removeEntryAsync(10)) + assertEquals(0, storage.listEntries().size) + + storage.shutdownAsync() + tempDir.toFile().deleteRecursively() + } + } +} From 2645c5e78dcf6ab52aa60f119f8fcc5144197922 Mon Sep 17 00:00:00 2001 From: "GuavaDealer (Idan Nehama)" Date: Mon, 28 Sep 2026 18:35:17 +0300 Subject: [PATCH 4/8] test: add unit test suites and configure kover code coverage - Add comprehensive unit test coverage across the base and platformapi modules covering events, commands, dialogs, storage, and configuration - Integrate the Kotlinx Kover coverage plugin with a 75% verification rule for base and platformapi - Update ResourceUtil to support directory file systems alongside JAR URIs to enable resource listing in test environments - Make ServerChangelogs and PluginConfig testable with setter methods, dynamic API lookup, and safe fallback initialization - Open ChangelogDialog class and expose dialog view helpers to facilitate UI testing - Add required test dependencies including Brigadier, Adventure, PacketEvents, Netty buffer, and SLF4J to test source sets Signed-off-by: GuavaDealer (Idan Nehama) --- build.gradle.kts | 29 ++ gradle/libs.versions.toml | 4 + src/base/build.gradle.kts | 5 + .../changelogs/base/ServerChangelogs.kt | 19 +- .../changelogs/base/config/PluginConfig.kt | 13 +- .../base/dialog/ui/ChangelogDialog.kt | 10 +- .../changelogs/base/util/ResourceUtil.kt | 17 +- .../changelogs/base/ServerChangelogsTest.kt | 276 +++++++++++++ .../changelogs/base/TestPacketEvents.kt | 43 ++ .../base/command/AbstractCommandTest.kt | 241 +++++++++++ .../base/command/BrigadierCommandNodeTest.kt | 161 ++++++++ .../base/command/PlayerArgumentTest.kt | 101 +++++ .../base/compat/PacketEventsFixTest.kt | 91 +++++ .../base/data/ChangelogEntryTest.kt | 55 +++ .../base/data/ChangelogStorageFactoryTest.kt | 68 +++ .../data/storage/YamlChangelogStorageTest.kt | 127 ++++++ .../base/dialog/DialogPacketsTest.kt | 186 +++++++++ .../base/dialog/DialogSessionManagerTest.kt | 113 +++++ .../base/dialog/PluginDialogTest.kt | 50 +++ .../base/dialog/ui/ChangelogDialogTest.kt | 289 +++++++++++++ .../ui/editor/ChangelogEditorDialogTest.kt | 386 ++++++++++++++++++ .../dialog/ui/editor/EditorSessionTest.kt | 133 ++++++ .../base/event/ChangelogJoinListenerTest.kt | 210 ++++++++++ .../base/lang/TranslationSourceTest.kt | 112 +++++ .../changelogs/base/util/ResourceUtilTest.kt | 34 ++ src/platformapi/build.gradle.kts | 2 + .../audience/PermissionAudienceWrapperTest.kt | 53 +++ .../event/PlatformEventManagerTest.kt | 87 ++++ .../event/impl/PlatformEventTest.kt | 43 ++ .../util/MethodDispatchingListenerTest.kt | 125 ++++++ .../event/util/PlatformEventMappingsTest.kt | 50 +++ 31 files changed, 3123 insertions(+), 10 deletions(-) create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/ServerChangelogsTest.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/TestPacketEvents.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/command/AbstractCommandTest.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/command/BrigadierCommandNodeTest.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/command/PlayerArgumentTest.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/compat/PacketEventsFixTest.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/data/ChangelogEntryTest.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/data/ChangelogStorageFactoryTest.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorageTest.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/DialogPacketsTest.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/DialogSessionManagerTest.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/PluginDialogTest.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialogTest.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialogTest.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/EditorSessionTest.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/event/ChangelogJoinListenerTest.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/lang/TranslationSourceTest.kt create mode 100644 src/base/src/test/kotlin/com/codingcat/changelogs/base/util/ResourceUtilTest.kt create mode 100644 src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/audience/PermissionAudienceWrapperTest.kt create mode 100644 src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/PlatformEventManagerTest.kt create mode 100644 src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/impl/PlatformEventTest.kt create mode 100644 src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/util/MethodDispatchingListenerTest.kt create mode 100644 src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappingsTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index a7e01e9..1fb6933 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("org.jetbrains.kotlin.jvm") apply false id("com.gradleup.shadow") apply false alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.kover) } group = project.findProperty("group")?.toString() ?: missingProperty("group") @@ -22,11 +23,16 @@ val cleanFinalArtifacts = tasks.register("cleanFinalArtifacts") { delete(finalFile) } +repositories { + mavenCentral() +} + @Suppress("AvoidApplyPluginMethod") subprojects { version = rootProject.version apply(plugin = "org.jetbrains.kotlin.jvm") apply(plugin = "java") + apply(plugin = "org.jetbrains.kotlinx.kover") java { toolchain.languageVersion = JavaLanguageVersion.of(javaVersion) @@ -84,3 +90,26 @@ tasks { } } } + +dependencies { + kover(project(":base")) + kover(project(":platformapi")) +} + +kover { + reports { + filters { + excludes { + packages("com.codingcat.changelogs.paper.*", "com.codingcat.changelogs.velocity.*") + } + } + total { + verify { + onCheck = true + rule { + minBound(75) + } + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4d7a186..ca34c0a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,8 @@ coroutines = "1.11.0" kotlinx-serialization = "1.11.0" exposed = "1.5.0" sqlite-jdbc = "3.53.4.0" +netty = "4.1.118.Final" +kover = "0.9.9" [libraries] kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } @@ -47,6 +49,7 @@ exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "e exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" } exposed-java-time = { module = "org.jetbrains.exposed:exposed-java-time", version.ref = "exposed" } sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite-jdbc" } +netty-buffer = { module = "io.netty:netty-buffer", version.ref = "netty" } [bundles] adventure = [ @@ -68,3 +71,4 @@ run-velocity = { id = "xyz.jpenilla.run-velocity", version.ref = "run-task" } resource-factory-paper-convention = { id = "xyz.jpenilla.resource-factory-paper-convention", version.ref = "resource-factory" } resource-factory-velocity-convention = { id = "xyz.jpenilla.resource-factory-velocity-convention", version.ref = "resource-factory" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } diff --git a/src/base/build.gradle.kts b/src/base/build.gradle.kts index 7ea3ce0..bbf259d 100644 --- a/src/base/build.gradle.kts +++ b/src/base/build.gradle.kts @@ -16,4 +16,9 @@ dependencies { compileOnly(libs.packetevents.api) testImplementation(libs.kotlin.test) testImplementation(libs.bundles.adventure) + testImplementation(libs.mojang.brigadier) + testImplementation(libs.packetevents.api) + testImplementation(libs.packetevents.paper) + testImplementation(libs.netty.buffer) + testImplementation(libs.slf4j.api) } diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/ServerChangelogs.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/ServerChangelogs.kt index 3f4a786..997e891 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/ServerChangelogs.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/ServerChangelogs.kt @@ -40,7 +40,8 @@ import kotlin.io.path.writeText object ServerChangelogs : Entrypoint { override lateinit var platform: ChangelogsPlatform - override val packetEventsApi: PacketEventsAPI by lazy { PacketEvents.getAPI() } + override val packetEventsApi: PacketEventsAPI + get() = PacketEvents.getAPI() /** * Adventure key namespace for ServerChangelogs identifiers. @@ -184,7 +185,21 @@ object ServerChangelogs : Entrypoint { /** * Active configuration settings instance. */ - fun pluginConfig(): PluginConfig = this.config + fun pluginConfig(): PluginConfig { + if (!::config.isInitialized) { + val configPath: Path = runCatching { platform.getDataPath().resolve("config.yml") } + .getOrElse { Path.of("config.yml") } + this.config = PluginConfig(configPath) + if (configPath.exists()) { + this.config.reload() + } + } + return this.config + } + + fun setConfig(config: PluginConfig) { + this.config = config + } /** * Logs an informational message translated for the console locale. diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt index 08ec31a..130dccc 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt @@ -187,10 +187,16 @@ class PluginConfig( return data.useNativeFallbackPermissions } - fun createChangelogHeaderStack(): ItemStack? = - data.dialogHeaderItem?.let(::createStack) + fun createChangelogHeaderStack(): ItemStack? { + val itemStr = data.dialogHeaderItem ?: return null + val platform = runCatching { ServerChangelogs.platform }.getOrNull() ?: return null + return createStack(itemStr, platform.nativeItemManager) + } - private fun createStack(input: String): ItemStack { + private fun createStack( + input: String, + nativeManager: com.codingcat.changelogs.platformapi.item.NativeItemManager, + ): ItemStack { val idPart = if (input.contains("[")) input.substring(0, input.indexOf('[')) else input val componentPart = if (input.contains("[")) input.substring(input.indexOf('[')) else null val inputKey = runCatching { @@ -198,7 +204,6 @@ class PluginConfig( }.getOrElse { e -> throw RuntimeException("Invalid item ID \"${idPart}\"", e) } - val nativeManager = ServerChangelogs.platform.nativeItemManager var nativeStack: Any = nativeManager.createNativeStack(inputKey, 1) ?: throw NullPointerException("Unknown item ID \"${idPart}\"") componentPart?.let { diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.kt index fdfd06f..bb06724 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.kt @@ -34,7 +34,7 @@ private const val HEADER_ITEM_WIDTH: Int = 17 private const val HEADER_ITEM_HEIGHT: Int = 17 private const val CLOSE_BUTTON_WIDTH: Int = 60 -class ChangelogDialog( +open class ChangelogDialog( private val storage: ChangelogStorage, private val holder: PluginDialog.Holder, private val dateFormatter: DateTimeFormatter, @@ -44,6 +44,14 @@ class ChangelogDialog( ) : PluginDialog { override val id: String = "changelog_view" + open fun showDialogView( + player: PlatformPlayer, + sessionManager: DialogSessionManager, + packetPhase: DialogPackets.PacketPhase, + ) { + showTo(player, sessionManager, packetPhase) + } + override fun build( player: PlatformPlayer, sessionManager: DialogSessionManager, diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/util/ResourceUtil.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/util/ResourceUtil.kt index 888381e..aea2e7f 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/util/ResourceUtil.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/util/ResourceUtil.kt @@ -5,6 +5,7 @@ import java.net.URISyntaxException import java.net.URL import java.nio.charset.StandardCharsets import java.nio.file.FileSystems +import java.nio.file.Path import kotlin.io.path.listDirectoryEntries import kotlin.io.path.name @@ -26,10 +27,20 @@ object ResourceUtil { fun listResources(dirPath: String): Collection { try { - val resourceUrl: URL = checkNotNull(classLoader.getResource(dirPath)) { "Resource not found" } + val resourceUrl: URL = checkNotNull(classLoader.getResource(dirPath)) { "Resource not found: ${dirPath}" } val uri = resourceUrl.toURI() - return FileSystems.newFileSystem(uri, emptyMap()).use { fileSystem -> - fileSystem.getPath(dirPath).listDirectoryEntries().map { it.name } + return if (uri.scheme.equals("jar", ignoreCase = true)) { + val (fileSystem, shouldClose) = runCatching { FileSystems.getFileSystem(uri) to false } + .getOrElse { FileSystems.newFileSystem(uri, emptyMap()) to true } + try { + fileSystem.getPath(dirPath).listDirectoryEntries().map { it.name } + } finally { + if (shouldClose) { + fileSystem.close() + } + } + } else { + Path.of(uri).listDirectoryEntries().map { it.name } } } catch (e: IOException) { throw RuntimeException("Failed to list jar resources at ${dirPath}", e) diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/ServerChangelogsTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/ServerChangelogsTest.kt new file mode 100644 index 0000000..8080b17 --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/ServerChangelogsTest.kt @@ -0,0 +1,276 @@ +package com.codingcat.changelogs.base + +import com.codingcat.changelogs.base.compat.PacketEventsNativeItemManager +import com.codingcat.changelogs.base.dialog.PluginDialog +import com.codingcat.changelogs.base.dialog.ui.editor.ChangelogEditorDialog +import com.codingcat.changelogs.platformapi.ChangelogsPlatform +import com.codingcat.changelogs.platformapi.command.PlatformCommandManager +import com.codingcat.changelogs.platformapi.command.PlatformCommandSource +import com.codingcat.changelogs.platformapi.event.PlatformEventManager +import com.codingcat.changelogs.platformapi.event.impl.PlatformEvent +import com.codingcat.changelogs.platformapi.item.NativeItemManager +import com.codingcat.changelogs.platformapi.meta.ChangelogsMeta +import com.codingcat.changelogs.platformapi.meta.PlatformMeta +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.codingcat.changelogs.platformapi.player.PlatformPlayerManager +import com.mojang.brigadier.CommandDispatcher +import com.mojang.brigadier.tree.LiteralCommandNode +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.permission.PermissionChecker +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.text.logger.slf4j.ComponentLogger +import net.kyori.adventure.util.TriState +import java.nio.file.Files +import java.nio.file.Path +import java.util.* +import kotlin.io.path.writeText +import kotlin.test.* + +class ServerChangelogsTest { + private lateinit var tempDir: Path + private val sentMessages = mutableListOf() + + private class DummyUser( + uuid: UUID = UUID.randomUUID(), + name: String = "AdminPlayer", + ) : com.github.retrooper.packetevents.protocol.player.User( + null, + com.github.retrooper.packetevents.protocol.ConnectionState.PLAY, + com.github.retrooper.packetevents.protocol.player.ClientVersion.V_1_21, + com.github.retrooper.packetevents.protocol.player.UserProfile(uuid, name), + ) { + override fun sendPacket(packet: com.github.retrooper.packetevents.wrapper.PacketWrapper<*>) {} + } + + private class TestPlayer( + override val name: String = "AdminPlayer", + val dummyUser: DummyUser = DummyUser(), + ) : PlatformPlayer { + override val uniqueId: UUID = dummyUser.uuid + override val clientLocale: Locale = Locale.US + override val isFirstJoin: Boolean = false + override val isNativeAdmin: TriState = TriState.TRUE + override fun asAudience(): Audience = Audience.empty() + override fun asPermissionChecker(): PermissionChecker = PermissionChecker { TriState.TRUE } + override fun kick(reason: Component?) {} + override fun asPacketEventsUser(): Any = dummyUser + } + + private class TestCommandSource( + val messages: MutableList, + override val executingPlayer: PlatformPlayer? = null, + ) : PlatformCommandSource { + override fun asAudience(): Audience = object : Audience { + override fun sendMessage(message: Component) { + messages.add(message) + } + } + + override fun asPermissionChecker(): PermissionChecker = PermissionChecker { TriState.TRUE } + } + + private class TestCommandManager : PlatformCommandManager { + val registeredNodes = mutableListOf>() + val dispatcher = CommandDispatcher() + + override fun register(node: LiteralCommandNode<*>, aliases: ImmutableSet) { + @Suppress("UNCHECKED_CAST") + val anyNode = node as LiteralCommandNode + registeredNodes.add(anyNode) + dispatcher.root.addChild(anyNode) + for (alias in aliases) { + dispatcher.register( + com.mojang.brigadier.builder.LiteralArgumentBuilder.literal(alias) + .redirect(anyNode), + ) + } + } + + override fun adaptPlatformSource(platformCommandSource: Any): PlatformCommandSource { + return platformCommandSource as PlatformCommandSource + } + } + + private class TestEventManager : PlatformEventManager { + val listeners = mutableListOf() + override fun registerListener(eventCls: Class, listener: (T) -> Unit) { + listeners.add(listener) + } + + override fun unregisterListener(listener: Any) { + listeners.remove(listener) + } + + override fun unregisterIf(listenerPredicate: (Any) -> Boolean) { + listeners.removeAll(listenerPredicate) + } + + override fun unregisterAll() { + listeners.clear() + } + } + + private class TestPlatform( + val dir: Path, + override val commandManager: TestCommandManager = TestCommandManager(), + override val eventManager: TestEventManager = TestEventManager(), + ) : ChangelogsPlatform { + override val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined) + override val nativeItemManager: NativeItemManager = PacketEventsNativeItemManager + override val playerManager: PlatformPlayerManager = object : PlatformPlayerManager { + val player = TestPlayer() + override val onlinePlayers: ImmutableSet = setOf(player).toPersistentSet() + override fun getFromName(name: String): PlatformPlayer? = if (name == player.name) player else null + override fun getFromUUID(uuid: UUID): PlatformPlayer? = if (uuid == player.uniqueId) player else null + override fun fromNative(nativePlayer: Any): PlatformPlayer = player + } + override val platformMeta: PlatformMeta = object : PlatformMeta { + override val name: Component = Component.text("TestServer", NamedTextColor.BLUE) + override val version: String = "1.0.0" + } + override val changelogsMeta: ChangelogsMeta = object : ChangelogsMeta { + override val name: String = "ServerChangelogs" + override val version: String = "2.0.0" + override val description: String = "Test Changelogs" + override val authors: Set = setOf("GuavaDealer") + } + + override fun getComponentLogger(): ComponentLogger = ComponentLogger.logger("TestServer") + override fun getDataPath(): Path = dir + } + + @BeforeTest + fun setUp() { + TestPacketEvents.setup() + tempDir = Files.createTempDirectory("server_changelogs_test") + } + + @AfterTest + fun tearDown() { + tempDir.toFile().deleteRecursively() + } + + @Test + fun testLifecycleStartReloadShutdown() = runBlocking { + val platform = TestPlatform(tempDir) + ServerChangelogs.platform = platform + + ServerChangelogs.onStart() + + assertNotNull(ServerChangelogs.changelogStorage) + assertNotNull(ServerChangelogs.logger) + assertNotNull(ServerChangelogs.pluginConfig()) + + // Logging helper tests + ServerChangelogs.info("console.startup") + ServerChangelogs.warn("console.startup") + ServerChangelogs.error("console.startup", RuntimeException("test error")) + + // Key helper tests + val key = ServerChangelogs.key("sample") + assertEquals(ServerChangelogs.NAMESPACE, key.namespace()) + assertEquals("sample", key.value()) + val generatedKey = ServerChangelogs.KEY_GENERATOR("sample2") + assertEquals("sample2", generatedKey.value()) + + // Reload + ServerChangelogs.reload(force = false) + ServerChangelogs.reload(force = true) + + // Reload failure on invalid config + tempDir.resolve("config.yml").writeText("invalid: [yaml: broken\n") + assertFailsWith { + ServerChangelogs.reload(force = false) + } + + // Restore valid config + tempDir.resolve("config.yml").writeText("use_native_fallback_permissions: false\n") + ServerChangelogs.reload(force = true) + + // Shutdown + ServerChangelogs.onShutdown() + } + + @Test + fun testCommandsExecution() = runBlocking { + val platform = TestPlatform(tempDir) + ServerChangelogs.platform = platform + + ServerChangelogs.onStart() + + val cmdManager = platform.commandManager + val source = TestCommandSource(sentMessages) + + // Execute root command + val rootResult = cmdManager.dispatcher.execute("server_changelogs", source) + assertEquals(com.mojang.brigadier.Command.SINGLE_SUCCESS, rootResult) + assertTrue(sentMessages.isNotEmpty()) + + // Execute info subcommand + val infoResult = cmdManager.dispatcher.execute("server_changelogs info", source) + assertEquals(com.mojang.brigadier.Command.SINGLE_SUCCESS, infoResult) + + // Execute reload subcommand + val reloadResult = cmdManager.dispatcher.execute("server_changelogs reload", source) + assertEquals(com.mojang.brigadier.Command.SINGLE_SUCCESS, reloadResult) + + // Execute reload --force subcommand + val reloadForceResult = cmdManager.dispatcher.execute("server_changelogs reload --force", source) + assertEquals(com.mojang.brigadier.Command.SINGLE_SUCCESS, reloadForceResult) + + // Execute aliases + val sclResult = cmdManager.dispatcher.execute("scl info", source) + assertEquals(com.mojang.brigadier.Command.SINGLE_SUCCESS, sclResult) + + val changelogsResult = cmdManager.dispatcher.execute("changelogs info", source) + assertEquals(com.mojang.brigadier.Command.SINGLE_SUCCESS, changelogsResult) + + // Dedicated command and player subcommands + val player = platform.playerManager.onlinePlayers.first() + val playerSource = TestCommandSource(sentMessages, executingPlayer = player) + + val viewResult = cmdManager.dispatcher.execute("server_changelogs view", playerSource) + assertEquals(com.mojang.brigadier.Command.SINGLE_SUCCESS, viewResult) + + val createResult = cmdManager.dispatcher.execute("server_changelogs create", playerSource) + assertEquals(com.mojang.brigadier.Command.SINGLE_SUCCESS, createResult) + + val dedicatedResult = cmdManager.dispatcher.execute("changelog", playerSource) + assertEquals(com.mojang.brigadier.Command.SINGLE_SUCCESS, dedicatedResult) + + ServerChangelogs.onShutdown() + } + + @Test + fun testReloadRejectionWhenEditorActive() = runBlocking { + val platform = TestPlatform(tempDir) + ServerChangelogs.platform = platform + + ServerChangelogs.onStart() + + // Simulate active saved session in editor dialog to trigger DestroyRejectedException + val editor = ServerChangelogs.dialogHolder.getFromType() + // Reflection to inject saved session into ChangelogEditorDialog.savedSessions + val savedSessionsField = ChangelogEditorDialog::class.java.getDeclaredField("savedSessions") + savedSessionsField.isAccessible = true + @Suppress("UNCHECKED_CAST") + val savedSessions = savedSessionsField.get(editor) as MutableMap + savedSessions[UUID.randomUUID()] = com.codingcat.changelogs.base.dialog.ui.editor.EditorSession.Create(1) + + // reload(force = false) should fail + assertFailsWith { + ServerChangelogs.reload(force = false) + } + + // reload(force = true) should succeed + ServerChangelogs.reload(force = true) + + ServerChangelogs.onShutdown() + } +} diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/TestPacketEvents.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/TestPacketEvents.kt new file mode 100644 index 0000000..d928465 --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/TestPacketEvents.kt @@ -0,0 +1,43 @@ +package com.codingcat.changelogs.base + +import com.github.retrooper.packetevents.PacketEvents +import com.github.retrooper.packetevents.PacketEventsAPI +import com.github.retrooper.packetevents.injector.ChannelInjector +import com.github.retrooper.packetevents.manager.player.PlayerManager +import com.github.retrooper.packetevents.manager.protocol.ProtocolManager +import com.github.retrooper.packetevents.manager.server.ServerManager +import com.github.retrooper.packetevents.manager.server.ServerVersion +import com.github.retrooper.packetevents.netty.NettyManager +import com.github.retrooper.packetevents.settings.PacketEventsSettings +import com.github.retrooper.packetevents.util.PEVersion + +object TestPacketEvents { + fun setup( + peVer: PEVersion = PEVersion(2, 14, 0), + srvVer: ServerVersion = ServerVersion.V_1_21, + ) { + val testSettings = PacketEventsSettings().apply { + customResourceProvider { path -> + PacketEventsAPI::class.java.classLoader.getResourceAsStream(path) + } + } + val api = object : PacketEventsAPI() { + override fun isLoaded(): Boolean = true + override fun init() {} + override fun isInitialized(): Boolean = true + override fun isTerminated(): Boolean = false + override fun getPlugin(): Any = "dummy" + override fun getSettings(): PacketEventsSettings = testSettings + override fun getServerManager(): ServerManager = object : ServerManager { + override fun getVersion(): ServerVersion = srvVer + } + + override fun getProtocolManager(): ProtocolManager = throw UnsupportedOperationException() + override fun getPlayerManager(): PlayerManager = throw UnsupportedOperationException() + override fun getNettyManager(): NettyManager = io.github.retrooper.packetevents.netty.NettyManagerImpl() + override fun getInjector(): ChannelInjector = throw UnsupportedOperationException() + override fun getVersion(): PEVersion = peVer + } + PacketEvents.setAPI(api) + } +} diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/command/AbstractCommandTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/command/AbstractCommandTest.kt new file mode 100644 index 0000000..a4907fb --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/command/AbstractCommandTest.kt @@ -0,0 +1,241 @@ +package com.codingcat.changelogs.base.command + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.base.config.PluginConfig +import com.codingcat.changelogs.platformapi.ChangelogsPlatform +import com.codingcat.changelogs.platformapi.command.PlatformCommandManager +import com.codingcat.changelogs.platformapi.command.PlatformCommandSource +import com.codingcat.changelogs.platformapi.event.PlatformEventManager +import com.codingcat.changelogs.platformapi.item.NativeItemManager +import com.codingcat.changelogs.platformapi.meta.ChangelogsMeta +import com.codingcat.changelogs.platformapi.meta.PlatformMeta +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.codingcat.changelogs.platformapi.player.PlatformPlayerManager +import com.github.retrooper.packetevents.protocol.item.ItemStack +import com.mojang.brigadier.CommandDispatcher +import com.mojang.brigadier.arguments.StringArgumentType +import com.mojang.brigadier.builder.LiteralArgumentBuilder +import com.mojang.brigadier.tree.LiteralCommandNode +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.key.Key +import net.kyori.adventure.permission.PermissionChecker +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.text.logger.slf4j.ComponentLogger +import net.kyori.adventure.util.TriState +import java.nio.file.Files +import java.nio.file.Path +import java.util.* +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AbstractCommandTest { + private class DummyPlayer( + override val name: String = "TestPlayer", + override val isNativeAdmin: TriState = TriState.NOT_SET, + ) : PlatformPlayer { + override val uniqueId: UUID = UUID.randomUUID() + override val clientLocale: Locale = Locale.US + override val isFirstJoin: Boolean = false + override fun asAudience(): Audience = Audience.empty() + override fun asPermissionChecker(): PermissionChecker = PermissionChecker { TriState.TRUE } + override fun kick(reason: Component?) {} + override fun asPacketEventsUser(): Any = Unit + } + + private class DummyCommandSource( + override val executingPlayer: PlatformPlayer?, + ) : PlatformCommandSource { + val messages = mutableListOf() + override fun asAudience(): Audience = object : Audience { + override fun sendMessage(message: Component) { + messages.add(message) + } + } + + override fun asPermissionChecker(): PermissionChecker = PermissionChecker { TriState.TRUE } + } + + private class DummyCommandManager : PlatformCommandManager { + override fun register(node: LiteralCommandNode<*>, aliases: ImmutableSet) {} + override fun adaptPlatformSource(platformCommandSource: Any): PlatformCommandSource { + return platformCommandSource as PlatformCommandSource + } + } + + private class DummyPlatform( + val dataDir: Path, + override val playerManager: PlatformPlayerManager, + override val commandManager: PlatformCommandManager = DummyCommandManager(), + ) : ChangelogsPlatform { + override fun getComponentLogger(): ComponentLogger = ComponentLogger.logger("Test") + override fun getDataPath(): Path = dataDir + override val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined) + override val eventManager: PlatformEventManager = object : PlatformEventManager { + override fun registerListener( + eventCls: Class, + listener: (T) -> Unit, + ) { + } + + override fun unregisterListener(listener: Any) {} + override fun unregisterIf(listenerPredicate: (Any) -> Boolean) {} + override fun unregisterAll() {} + } + override val nativeItemManager: NativeItemManager = object : NativeItemManager { + override fun adaptToPEStack(nativeStack: Any): ItemStack = throw UnsupportedOperationException() + override fun createNativeStack(key: Key, amount: Int): Any? = null + override fun applyComponentStr(nativeStack: Any, componentStr: String): Any = nativeStack + } + override val platformMeta: PlatformMeta = object : PlatformMeta { + override val name: Component = Component.text("MockServer", NamedTextColor.GREEN) + override val version: String = "1.0" + } + override val changelogsMeta: ChangelogsMeta = object : ChangelogsMeta { + override val name: String = "ServerChangelogs" + override val version: String = "1.0" + override val description: String = "Desc" + override val authors: Set = setOf("Author") + } + } + + private class TestCommand( + featureEnabled: Boolean = true, + ) : AbstractCommand( + name = "testcmd", + description = "Test description", + aliases = listOf("tcmd"), + permission = null, + featureEnabled = featureEnabled, + ) { + var playerRan = false + var sourceRan = false + var suspendPlayerRan = false + var suspendSourceRan = false + + override fun buildCommand(builder: LiteralArgumentBuilder) { + builder.executesSource { _, _ -> sourceRan = true } + + builder.literal("playeronly") { + executesPlayer { _, _ -> playerRan = true } + } + + builder.literal("playersuspend") { + executesPlayerSuspend { _, _ -> suspendPlayerRan = true } + } + + builder.literal("sourcesuspend") { + executesSourceSuspend { _, _ -> suspendSourceRan = true } + } + + builder.literal("witharg") { + argument("str", StringArgumentType.word()) { + executes { com.mojang.brigadier.Command.SINGLE_SUCCESS } + } + } + + builder.literal("targetplayer") { + player("target") { + executes { com.mojang.brigadier.Command.SINGLE_SUCCESS } + } + } + } + } + + @Test + fun testCommandExecutionAndDsl() { + val tempDir = Files.createTempDirectory("abstract_cmd_test") + val configFile = tempDir.resolve("config.yml") + configFile.writeText("use_native_fallback_permissions: false\n") + + val player = DummyPlayer("Alex") + val playerManager = object : PlatformPlayerManager { + override val onlinePlayers: ImmutableSet = setOf(player).toPersistentSet() + override fun getFromName(name: String): PlatformPlayer? = if (name == "Alex") player else null + override fun getFromUUID(uuid: UUID): PlatformPlayer? = if (uuid == player.uniqueId) player else null + override fun fromNative(nativePlayer: Any): PlatformPlayer = player + } + + val platform = DummyPlatform(tempDir, playerManager) + ServerChangelogs.platform = platform + ServerChangelogs.setConfig(PluginConfig(configFile).also { it.reload() }) + + val cmd = TestCommand(featureEnabled = true) + val node = cmd.build(ServerChangelogs) + assertEquals("testcmd", node.name) + assertEquals(listOf("tcmd"), cmd.aliases) + assertEquals("Test description", cmd.description) + + val dispatcher = CommandDispatcher() + dispatcher.root.addChild(node) + + val playerSource = DummyCommandSource(executingPlayer = player) + val consoleSource = DummyCommandSource(executingPlayer = null) + + // Execute root source + dispatcher.execute("testcmd", playerSource) + assertTrue(cmd.sourceRan) + + // Execute player-only from player + dispatcher.execute("testcmd playeronly", playerSource) + assertTrue(cmd.playerRan) + + // Execute player-only from console -> should be rejected and send message + dispatcher.execute("testcmd playeronly", consoleSource) + assertEquals(1, consoleSource.messages.size) + + // Execute player suspend + dispatcher.execute("testcmd playersuspend", playerSource) + assertTrue(cmd.suspendPlayerRan) + + // Execute source suspend + dispatcher.execute("testcmd sourcesuspend", consoleSource) + assertTrue(cmd.suspendSourceRan) + + // Execute with arg + val argRes = dispatcher.execute("testcmd witharg hello", playerSource) + assertEquals(com.mojang.brigadier.Command.SINGLE_SUCCESS, argRes) + + // Execute targetplayer + val playerRes = dispatcher.execute("testcmd targetplayer Alex", playerSource) + assertEquals(com.mojang.brigadier.Command.SINGLE_SUCCESS, playerRes) + + tempDir.toFile().deleteRecursively() + } + + @Test + fun testDisabledFeatureCommand() { + val tempDir = Files.createTempDirectory("abstract_cmd_disabled_test") + val configFile = tempDir.resolve("config.yml") + configFile.writeText("use_native_fallback_permissions: false\n") + + val platform = DummyPlatform( + tempDir, + object : PlatformPlayerManager { + override val onlinePlayers: ImmutableSet = emptySet().toPersistentSet() + override fun getFromName(name: String): PlatformPlayer? = null + override fun getFromUUID(uuid: UUID): PlatformPlayer? = null + override fun fromNative(nativePlayer: Any): PlatformPlayer = throw UnsupportedOperationException() + }, + ) + ServerChangelogs.platform = platform + + val cmd = TestCommand(featureEnabled = false) + val node = cmd.build(ServerChangelogs) + + val dispatcher = CommandDispatcher() + dispatcher.root.addChild(node) + + val source = DummyCommandSource(executingPlayer = null) + dispatcher.execute("testcmd", source) + assertEquals(1, source.messages.size) + + tempDir.toFile().deleteRecursively() + } +} diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/command/BrigadierCommandNodeTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/command/BrigadierCommandNodeTest.kt new file mode 100644 index 0000000..8205a4b --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/command/BrigadierCommandNodeTest.kt @@ -0,0 +1,161 @@ +package com.codingcat.changelogs.base.command + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.base.config.PluginConfig +import com.codingcat.changelogs.platformapi.ChangelogsPlatform +import com.codingcat.changelogs.platformapi.command.PlatformCommandManager +import com.codingcat.changelogs.platformapi.command.PlatformCommandSource +import com.codingcat.changelogs.platformapi.event.PlatformEventManager +import com.codingcat.changelogs.platformapi.item.NativeItemManager +import com.codingcat.changelogs.platformapi.meta.ChangelogsMeta +import com.codingcat.changelogs.platformapi.meta.PlatformMeta +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.codingcat.changelogs.platformapi.player.PlatformPlayerManager +import com.github.retrooper.packetevents.protocol.item.ItemStack +import com.mojang.brigadier.tree.LiteralCommandNode +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.key.Key +import net.kyori.adventure.permission.PermissionChecker +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.text.logger.slf4j.ComponentLogger +import net.kyori.adventure.util.TriState +import java.nio.file.Files +import java.nio.file.Path +import java.util.* +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BrigadierCommandNodeTest { + private class DummyPlayer( + override val name: String = "TestPlayer", + override val isNativeAdmin: TriState = TriState.NOT_SET, + private val permissions: Map = emptyMap(), + ) : PlatformPlayer { + override val uniqueId: UUID = UUID.randomUUID() + override val clientLocale: Locale = Locale.US + override val isFirstJoin: Boolean = false + override fun asAudience(): Audience = Audience.empty() + override fun asPermissionChecker(): PermissionChecker = + PermissionChecker { permissions[it] ?: TriState.NOT_SET } + + override fun kick(reason: Component?) {} + override fun asPacketEventsUser(): Any = Unit + } + + private class DummyCommandSource( + override val executingPlayer: PlatformPlayer?, + private val permissions: Map = emptyMap(), + ) : PlatformCommandSource { + override fun asAudience(): Audience = Audience.empty() + override fun asPermissionChecker(): PermissionChecker = + PermissionChecker { permissions[it] ?: TriState.NOT_SET } + } + + private class DummyCommandManager : PlatformCommandManager { + override fun register(node: LiteralCommandNode<*>, aliases: ImmutableSet) {} + override fun adaptPlatformSource(platformCommandSource: Any): PlatformCommandSource { + return platformCommandSource as PlatformCommandSource + } + } + + private class DummyPlatform( + val dataDir: Path, + override val commandManager: PlatformCommandManager = DummyCommandManager(), + ) : ChangelogsPlatform { + override fun getComponentLogger(): ComponentLogger = ComponentLogger.logger("Test") + override fun getDataPath(): Path = dataDir + override val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined) + override val playerManager: PlatformPlayerManager = object : PlatformPlayerManager { + override val onlinePlayers: ImmutableSet = emptySet().toPersistentSet() + override fun getFromName(name: String): PlatformPlayer? = null + override fun getFromUUID(uuid: UUID): PlatformPlayer? = null + override fun fromNative(nativePlayer: Any): PlatformPlayer = throw UnsupportedOperationException() + } + override val eventManager: PlatformEventManager = object : PlatformEventManager { + override fun registerListener( + eventCls: Class, + listener: (T) -> Unit, + ) { + } + + override fun unregisterListener(listener: Any) {} + override fun unregisterIf(listenerPredicate: (Any) -> Boolean) {} + override fun unregisterAll() {} + } + override val nativeItemManager: NativeItemManager = object : NativeItemManager { + override fun adaptToPEStack(nativeStack: Any): ItemStack = throw UnsupportedOperationException() + override fun createNativeStack(key: Key, amount: Int): Any? = null + override fun applyComponentStr(nativeStack: Any, componentStr: String): Any = nativeStack + } + override val platformMeta: PlatformMeta = object : PlatformMeta { + override val name: Component = Component.text("MockServer", NamedTextColor.GREEN) + override val version: String = "1.0" + } + override val changelogsMeta: ChangelogsMeta = object : ChangelogsMeta { + override val name: String = "ServerChangelogs" + override val version: String = "1.0" + override val description: String = "Desc" + override val authors: Set = setOf("Author") + } + } + + @Test + fun testRequirePermissionStandard() { + val tempDir = Files.createTempDirectory("cmd_perm_test") + val configFile = tempDir.resolve("config.yml") + configFile.writeText("use_native_fallback_permissions: false\n") + + val platform = DummyPlatform(tempDir) + ServerChangelogs.platform = platform + ServerChangelogs.setConfig(PluginConfig(configFile).also { it.reload() }) + + val predicate = BrigadierCommandNode.requirePermission("test.perm", ServerChangelogs, false) + + val grantedSource = DummyCommandSource( + executingPlayer = null, + permissions = mapOf("server_changelogs.test.perm" to TriState.TRUE), + ) + assertTrue(predicate(grantedSource)) + + val deniedSource = DummyCommandSource( + executingPlayer = null, + permissions = mapOf("server_changelogs.test.perm" to TriState.FALSE), + ) + assertFalse(predicate(deniedSource)) + + tempDir.toFile().deleteRecursively() + } + + @Test + fun testRequirePermissionNativeFallback() { + val tempDir = Files.createTempDirectory("cmd_native_perm_test") + val configFile = tempDir.resolve("config.yml") + configFile.writeText("use_native_fallback_permissions: true\n") + + val platform = DummyPlatform(tempDir) + ServerChangelogs.platform = platform + ServerChangelogs.setConfig(PluginConfig(configFile).also { it.reload() }) + + val predicate = BrigadierCommandNode.requirePermission("test.perm", ServerChangelogs, false) + + val adminPlayer = DummyPlayer(isNativeAdmin = TriState.TRUE) + val adminSource = DummyCommandSource(executingPlayer = adminPlayer) + assertTrue(predicate(adminSource)) + + val nonAdminPlayer = DummyPlayer(isNativeAdmin = TriState.FALSE) + val nonAdminSource = DummyCommandSource(executingPlayer = nonAdminPlayer) + assertFalse(predicate(nonAdminSource)) + + val consoleSource = DummyCommandSource(executingPlayer = null) + assertTrue(predicate(consoleSource)) + + tempDir.toFile().deleteRecursively() + } +} diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/command/PlayerArgumentTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/command/PlayerArgumentTest.kt new file mode 100644 index 0000000..2b65099 --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/command/PlayerArgumentTest.kt @@ -0,0 +1,101 @@ +package com.codingcat.changelogs.base.command.argument + +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.codingcat.changelogs.platformapi.player.PlatformPlayerManager +import com.mojang.brigadier.StringReader +import com.mojang.brigadier.exceptions.CommandSyntaxException +import com.mojang.brigadier.suggestion.SuggestionsBuilder +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toPersistentSet +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.permission.PermissionChecker +import net.kyori.adventure.text.Component +import net.kyori.adventure.util.TriState +import java.util.* +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class PlayerArgumentTest { + private class TestPlayer( + override val name: String, + override val uniqueId: UUID = UUID.randomUUID(), + ) : PlatformPlayer { + override val clientLocale: Locale = Locale.US + override val isFirstJoin: Boolean = false + override val isNativeAdmin: TriState = TriState.NOT_SET + override fun asAudience(): Audience = Audience.empty() + override fun asPermissionChecker(): PermissionChecker = PermissionChecker { TriState.TRUE } + override fun kick(reason: Component?) {} + override fun asPacketEventsUser(): Any = Unit + } + + private class TestPlayerManager( + val players: List, + ) : PlatformPlayerManager { + override val onlinePlayers: ImmutableSet = players.toPersistentSet() + override fun getFromName(name: String): PlatformPlayer? = + players.find { it.name.equals(name, ignoreCase = true) } + + override fun getFromUUID(uuid: UUID): PlatformPlayer? = players.find { it.uniqueId == uuid } + override fun fromNative(nativePlayer: Any): PlatformPlayer = throw UnsupportedOperationException() + } + + @Test + fun testParseValidPlayer() { + val player = TestPlayer("Steve") + val manager = TestPlayerManager(listOf(player)) + val argument = PlayerArgument(manager) + + val reader = StringReader("Steve") + val parsed = argument.parse(reader) + assertEquals("Steve", parsed.name) + } + + @Test + fun testParseInvalidNameThrows() { + val manager = TestPlayerManager(emptyList()) + val argument = PlayerArgument(manager) + + val reader = StringReader("!!invalid!!") + assertFailsWith { + argument.parse(reader) + } + } + + @Test + fun testParsePlayerNotFoundThrows() { + val manager = TestPlayerManager(emptyList()) + val argument = PlayerArgument(manager) + + val reader = StringReader("UnknownPlayer") + assertFailsWith { + argument.parse(reader) + } + } + + @Test + fun testListSuggestions() { + val player1 = TestPlayer("Alice") + val player2 = TestPlayer("Alex") + val player3 = TestPlayer("Bob") + val manager = TestPlayerManager(listOf(player1, player2, player3)) + val argument = PlayerArgument(manager) + + val builder = SuggestionsBuilder("al", 0) + val dispatcher = com.mojang.brigadier.CommandDispatcher() + val dummyContext = com.mojang.brigadier.context.CommandContextBuilder( + dispatcher, + Any(), + dispatcher.root, + 0, + ).build("al") + val suggestions = argument.listSuggestions(dummyContext, builder).get() + + val matchedNames = suggestions.list.map { it.text } + assertTrue(matchedNames.contains("Alice")) + assertTrue(matchedNames.contains("Alex")) + assertEquals(2, matchedNames.size) + } +} diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/compat/PacketEventsFixTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/compat/PacketEventsFixTest.kt new file mode 100644 index 0000000..f7529bf --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/compat/PacketEventsFixTest.kt @@ -0,0 +1,91 @@ +package com.codingcat.changelogs.base.compat + +import com.codingcat.changelogs.base.TestPacketEvents +import com.github.retrooper.packetevents.manager.server.ServerVersion +import com.github.retrooper.packetevents.protocol.component.ComponentTypes +import com.github.retrooper.packetevents.protocol.dialog.body.ItemDialogBody +import com.github.retrooper.packetevents.protocol.item.ItemStack +import com.github.retrooper.packetevents.protocol.item.type.ItemTypes +import com.github.retrooper.packetevents.util.PEVersion +import net.kyori.adventure.key.Key +import net.kyori.adventure.text.logger.slf4j.ComponentLogger +import org.slf4j.kotlin.KLogger +import kotlin.test.* + +class PacketEventsFixTest { + private val testLogger = KLogger(ComponentLogger.logger("PacketEventsFixTest")) + + @Test + fun testWorkaroundDetectionWhenAffected() { + TestPacketEvents.setup(PEVersion(2, 13, 0), ServerVersion.V_1_21) + PacketEventsFix.checkAndLoad(testLogger) + assertTrue(PacketEventsFix.isEnabled(PacketEventsFix.Workaround.INVALID_ITEM_BODY_ENCODING)) + } + + @Test + fun testWorkaroundDetectionWhenNotAffected() { + TestPacketEvents.setup(PEVersion(2, 14, 0), ServerVersion.V_1_21) + PacketEventsFix.checkAndLoad(testLogger) + assertFalse(PacketEventsFix.isEnabled(PacketEventsFix.Workaround.INVALID_ITEM_BODY_ENCODING)) + } + + @Test + fun testSetManualWorkarounds() { + TestPacketEvents.setup(PEVersion(2, 14, 0), ServerVersion.V_1_21) + PacketEventsFix.setManualWorkarounds(setOf(PacketEventsFix.Workaround.INVALID_ITEM_BODY_ENCODING), testLogger) + assertTrue(PacketEventsFix.isEnabled(PacketEventsFix.Workaround.INVALID_ITEM_BODY_ENCODING)) + + PacketEventsFix.setManualWorkarounds(emptySet(), testLogger) + assertFalse(PacketEventsFix.isEnabled(PacketEventsFix.Workaround.INVALID_ITEM_BODY_ENCODING)) + } + + @Test + fun testFixItemBody() { + TestPacketEvents.setup(PEVersion(2, 13, 0), ServerVersion.V_1_21) + PacketEventsFix.checkAndLoad(testLogger) + + val item = ItemStack.builder().type(ItemTypes.PAPER).amount(1).build() + val itemBody = ItemDialogBody(item, null, false, false, 1, 1) + + assertFalse(item.hasComponent(ComponentTypes.FOOD)) + PacketEventsFix.fixItemBody(itemBody) + assertTrue(item.hasComponent(ComponentTypes.FOOD)) + + // Calling fixItemBody again should return early because hasComponentPatches is true + PacketEventsFix.fixItemBody(itemBody) + assertTrue(item.hasComponent(ComponentTypes.FOOD)) + + // Disable workaround by setting non-affected version and test that fixItemBody is no-op + TestPacketEvents.setup(PEVersion(2, 14, 0), ServerVersion.V_1_21) + PacketEventsFix.setManualWorkarounds(emptySet(), testLogger) + val freshItem = ItemStack.builder().type(ItemTypes.STICK).amount(1).build() + val freshItemBody = ItemDialogBody(freshItem, null, false, false, 1, 1) + PacketEventsFix.fixItemBody(freshItemBody) + assertFalse(freshItem.hasComponent(ComponentTypes.FOOD)) + } + + @Test + fun testNativeItemManager() { + val stack = ItemStack.builder().type(ItemTypes.BOOK).amount(2).build() + val adapted = PacketEventsNativeItemManager.adaptToPEStack(stack) + assertEquals(stack, adapted) + + assertFailsWith { + PacketEventsNativeItemManager.adaptToPEStack("not-a-stack") + } + + val created = PacketEventsNativeItemManager.createNativeStack(Key.key("minecraft", "diamond"), 5) + assertNotNull(created) + assertTrue(created is ItemStack) + assertEquals(5, created.amount) + + val nonExistent = PacketEventsNativeItemManager.createNativeStack( + Key.key("minecraft", "non_existent_item_xyz"), + 1, + ) + assertEquals(null, nonExistent) + + val applied = PacketEventsNativeItemManager.applyComponentStr(stack, "custom_components") + assertEquals(stack, applied) + } +} diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/data/ChangelogEntryTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/data/ChangelogEntryTest.kt new file mode 100644 index 0000000..9b9cf09 --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/data/ChangelogEntryTest.kt @@ -0,0 +1,55 @@ +package com.codingcat.changelogs.base.data + +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.permission.PermissionChecker +import net.kyori.adventure.text.Component +import net.kyori.adventure.util.TriState +import java.time.Instant +import java.util.* +import kotlin.test.* + +class ChangelogEntryTest { + private class TestPlayer( + override val uniqueId: UUID, + override val name: String = "TestPlayer", + ) : PlatformPlayer { + override val clientLocale: Locale = Locale.US + override val isFirstJoin: Boolean = false + override val isNativeAdmin: TriState = TriState.NOT_SET + override fun asAudience(): Audience = Audience.empty() + override fun asPermissionChecker(): PermissionChecker = PermissionChecker { TriState.TRUE } + override fun kick(reason: Component?) {} + override fun asPacketEventsUser(): Any = Unit + } + + @Test + fun testChangelogEntryPropertiesAndHasRead() { + val playerUuid1 = UUID.randomUUID() + val playerUuid2 = UUID.randomUUID() + val player1 = TestPlayer(playerUuid1) + val player2 = TestPlayer(playerUuid2) + + val now = Instant.now() + val entry = ChangelogEntry( + uid = 1, + lines = listOf(Component.text("Line 1"), Component.text("Line 2")), + recordedAt = now, + author = Component.text("Author"), + playersRead = mutableSetOf(playerUuid1), + ) + + assertEquals(1, entry.uid) + assertEquals(2, entry.lines.size) + assertEquals(now, entry.recordedAt) + assertNotNull(entry.author) + assertTrue(entry.hasRead(player1)) + assertFalse(entry.hasRead(player2)) + + val copy = entry.copy(uid = 2) + assertEquals(2, copy.uid) + assertEquals(entry.recordedAt, copy.recordedAt) + assertEquals(entry.hashCode(), entry.hashCode()) + assertTrue(entry.toString().contains("1")) + } +} diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/data/ChangelogStorageFactoryTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/data/ChangelogStorageFactoryTest.kt new file mode 100644 index 0000000..a03ddb3 --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/data/ChangelogStorageFactoryTest.kt @@ -0,0 +1,68 @@ +package com.codingcat.changelogs.base.data + +import com.codingcat.changelogs.base.config.PluginConfig +import com.codingcat.changelogs.base.data.storage.ExposedChangelogStorage +import com.codingcat.changelogs.base.data.storage.YamlChangelogStorage +import java.nio.file.Files +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class ChangelogStorageFactoryTest { + @Test + fun testCreateYamlStorage() { + val tempDir = Files.createTempDirectory("storage_factory_yaml") + val configFile = tempDir.resolve("config.yml") + configFile.writeText("changelog_storage: \"yaml\"\n") + + val config = PluginConfig(configFile) + config.reload() + val storage = ChangelogStorage.create(config, tempDir) + + assertTrue(storage is YamlChangelogStorage) + assertEquals("YAML File", storage.displayName) + + tempDir.toFile().deleteRecursively() + } + + @Test + fun testCreateExposedStorageVariants() { + val tempDir = Files.createTempDirectory("storage_factory_exposed") + val types = listOf("sqlite", "h2", "mysql", "mariadb", "postgresql", "postgres", "exposed", "jdbc") + + for (type in types) { + val configFile = tempDir.resolve("config_${type}.yml") + configFile.writeText( + """ + changelog_storage: "${type}" + database: + type: "sqlite" + """.trimIndent(), + ) + val config = PluginConfig(configFile) + config.reload() + val storage = ChangelogStorage.create(config, tempDir) + assertTrue(storage is ExposedChangelogStorage) + } + + tempDir.toFile().deleteRecursively() + } + + @Test + fun testUnknownStorageThrows() { + val tempDir = Files.createTempDirectory("storage_factory_err") + val configFile = tempDir.resolve("config_bad.yml") + configFile.writeText("changelog_storage: \"redis\"\n") + + val config = PluginConfig(configFile) + config.reload() + + assertFailsWith { + ChangelogStorage.create(config, tempDir) + } + + tempDir.toFile().deleteRecursively() + } +} diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorageTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorageTest.kt new file mode 100644 index 0000000..f50a642 --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorageTest.kt @@ -0,0 +1,127 @@ +package com.codingcat.changelogs.base.data.storage + +import com.codingcat.changelogs.base.data.ChangelogEntry +import kotlinx.coroutines.runBlocking +import net.kyori.adventure.text.Component +import java.nio.file.Files +import java.time.Instant +import java.util.* +import kotlin.io.path.writeText +import kotlin.test.* + +class YamlChangelogStorageTest { + @Test + fun testInitCreatesEmptyFile() { + val tempDir = Files.createTempDirectory("yaml_storage_test") + val yamlFile = tempDir.resolve("_data.yml") + val storage = YamlChangelogStorage(yamlFile) + + assertEquals("YAML File", storage.displayName) + storage.init() + assertTrue(Files.exists(yamlFile)) + assertEquals(0, storage.listEntries().size) + assertEquals(0, storage.nextUID()) + storage.shutdown() + + tempDir.toFile().deleteRecursively() + } + + @Test + fun testCrudAndPersistence() { + val tempDir = Files.createTempDirectory("yaml_crud_test") + val yamlFile = tempDir.resolve("_data.yml") + val storage = YamlChangelogStorage(yamlFile) + storage.init() + + val playerUuid = UUID.randomUUID() + val entry1 = ChangelogEntry( + uid = 1, + lines = listOf(Component.text("Line 1")), + recordedAt = Instant.now(), + author = Component.text("Author 1"), + playersRead = mutableSetOf(), + ) + + storage.storeEntry(entry1) + assertEquals(1, storage.listEntries().size) + assertEquals(2, storage.nextUID()) + + val entry2 = ChangelogEntry( + uid = 1, + lines = listOf(Component.text("Updated Line 1")), + recordedAt = entry1.recordedAt, + author = Component.text("Author 1"), + playersRead = mutableSetOf(), + ) + storage.updateEntry(entry2) + assertEquals(1, storage.listEntries().size) + assertEquals( + "Updated Line 1", + (storage.getByUID(1)?.lines?.firstOrNull() as? net.kyori.adventure.text.TextComponent)?.content() + ) + + storage.markAsRead(1, playerUuid) + assertTrue(storage.getByUID(1)?.playersRead?.contains(playerUuid) == true) + + // Reload from file to ensure persistence + val storage2 = YamlChangelogStorage(yamlFile) + storage2.init() + val loaded = storage2.getByUID(1) + assertNotNull(loaded) + assertTrue(loaded.playersRead.contains(playerUuid)) + + assertTrue(storage2.removeEntry(1)) + assertFalse(storage2.removeEntry(999)) + assertNull(storage2.getByUID(1)) + + tempDir.toFile().deleteRecursively() + } + + @Test + fun testAsyncOperations() { + runBlocking { + val tempDir = Files.createTempDirectory("yaml_async_test") + val yamlFile = tempDir.resolve("_data.yml") + val storage = YamlChangelogStorage(yamlFile) + + storage.initAsync() + + val entry = ChangelogEntry( + uid = 5, + lines = listOf(Component.text("Async line")), + recordedAt = Instant.now(), + author = null, + playersRead = mutableSetOf(), + ) + + storage.storeEntryAsync(entry) + assertEquals(1, storage.listEntries().size) + assertEquals(6, storage.nextUID()) + + val uuid = UUID.randomUUID() + storage.markAllAsReadAsync(listOf(5), uuid) + assertTrue(storage.getByUID(5)?.playersRead?.contains(uuid) == true) + + storage.saveAsync() + + assertTrue(storage.removeEntryAsync(5)) + storage.shutdownAsync() + + tempDir.toFile().deleteRecursively() + } + } + + @Test + fun testCorruptFileThrows() { + val tempDir = Files.createTempDirectory("yaml_corrupt_test") + val yamlFile = tempDir.resolve("_data.yml") + yamlFile.writeText("invalid: yaml: [broken") + val storage = YamlChangelogStorage(yamlFile) + + assertFailsWith { + storage.init() + } + + tempDir.toFile().deleteRecursively() + } +} diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/DialogPacketsTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/DialogPacketsTest.kt new file mode 100644 index 0000000..cba7026 --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/DialogPacketsTest.kt @@ -0,0 +1,186 @@ +package com.codingcat.changelogs.base.dialog + +import com.codingcat.changelogs.base.TestPacketEvents +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.github.retrooper.packetevents.protocol.ConnectionState +import com.github.retrooper.packetevents.protocol.dialog.CommonDialogData +import com.github.retrooper.packetevents.protocol.dialog.DialogAction +import com.github.retrooper.packetevents.protocol.dialog.MultiActionDialog +import com.github.retrooper.packetevents.protocol.dialog.NoticeDialog +import com.github.retrooper.packetevents.protocol.dialog.action.DynamicCustomAction +import com.github.retrooper.packetevents.protocol.player.ClientVersion +import com.github.retrooper.packetevents.protocol.player.User +import com.github.retrooper.packetevents.protocol.player.UserProfile +import com.github.retrooper.packetevents.resources.ResourceLocation +import com.github.retrooper.packetevents.wrapper.PacketWrapper +import com.github.retrooper.packetevents.wrapper.configuration.server.WrapperConfigServerClearDialog +import com.github.retrooper.packetevents.wrapper.configuration.server.WrapperConfigServerShowDialog +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerClearDialog +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerShowDialog +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.key.Key +import net.kyori.adventure.permission.PermissionChecker +import net.kyori.adventure.text.Component +import net.kyori.adventure.util.TriState +import java.util.* +import kotlin.test.* + +class DialogPacketsTest { + @BeforeTest + fun setUp() { + TestPacketEvents.setup() + } + + private class FakeUser( + uuid: UUID = UUID.randomUUID(), + name: String = "TestPlayer", + ) : User(null, ConnectionState.PLAY, ClientVersion.V_1_21, UserProfile(uuid, name)) { + val sentPackets = mutableListOf>() + var closedConnection = false + + override fun sendPacket(packet: PacketWrapper<*>) { + sentPackets.add(packet) + } + + override fun closeConnection() { + closedConnection = true + } + } + + private class FakePlayer( + val user: FakeUser, + override val name: String = user.name ?: "TestPlayer", + override val uniqueId: UUID = user.uuid, + ) : PlatformPlayer { + override val clientLocale: Locale = Locale.US + override val isFirstJoin: Boolean = false + override val isNativeAdmin: TriState = TriState.NOT_SET + override fun asAudience(): Audience = Audience.empty() + override fun asPermissionChecker(): PermissionChecker = PermissionChecker { TriState.TRUE } + override fun kick(reason: Component?) {} + override fun asPacketEventsUser(): Any = user + } + + private fun createDummyDialog(): NoticeDialog { + val common = CommonDialogData( + Component.text("Title"), + null, + true, + false, + DialogAction.CLOSE, + emptyList(), + mutableListOf(), + ) + return NoticeDialog(common, NoticeDialog.DEFAULT_ACTION) + } + + @Test + fun testShowDialogPlayAndConfig() { + val user = FakeUser() + val player = FakePlayer(user) + val dialog = createDummyDialog() + + player.showDialog(dialog, DialogPackets.PacketPhase.PLAY) + assertEquals(1, user.sentPackets.size) + assertTrue(user.sentPackets[0] is WrapperPlayServerShowDialog) + + player.showDialog(dialog, DialogPackets.PacketPhase.CONFIGURATION) + assertEquals(2, user.sentPackets.size) + assertTrue(user.sentPackets[1] is WrapperConfigServerShowDialog) + } + + @Test + fun testClearDialogPlayAndConfig() { + val user = FakeUser() + val player = FakePlayer(user) + + player.clearDialog(DialogPackets.PacketPhase.PLAY) + assertEquals(1, user.sentPackets.size) + assertTrue(user.sentPackets[0] is WrapperPlayServerClearDialog) + + player.clearDialog(DialogPackets.PacketPhase.CONFIGURATION) + assertEquals(2, user.sentPackets.size) + assertTrue(user.sentPackets[1] is WrapperConfigServerClearDialog) + } + + @Test + fun testShowSimpleConfirm() { + val user = FakeUser() + val player = FakePlayer(user) + + val confirmAction = DynamicCustomAction(ResourceLocation(Key.key("test", "confirm")), null) + val cancelAction = DynamicCustomAction(ResourceLocation(Key.key("test", "cancel")), null) + + DialogPackets.showSimpleConfirm( + player, + "dialog.popup.title", + "dialog.popup.body", + confirmAction, + confirmDanger = false, + cancelAction, + DialogPackets.PacketPhase.PLAY, + ) + + assertEquals(1, user.sentPackets.size) + val packet = user.sentPackets[0] as WrapperPlayServerShowDialog + assertTrue(packet.dialog is MultiActionDialog) + + // Test with confirmDanger = true + DialogPackets.showSimpleConfirm( + player, + "dialog.popup.title", + "dialog.popup.body", + confirmAction, + confirmDanger = true, + cancelAction, + DialogPackets.PacketPhase.PLAY, + ) + assertEquals(2, user.sentPackets.size) + } + + @Test + fun testShowSimpleNotice() { + val user = FakeUser() + val player = FakePlayer(user) + + // Notice with action + val closeAction = DynamicCustomAction(ResourceLocation(Key.key("test", "close")), null) + DialogPackets.showSimpleNotice( + player, + "dialog.popup.title", + "dialog.popup.body", + closeAction, + DialogPackets.PacketPhase.PLAY, + ) + assertEquals(1, user.sentPackets.size) + val packet = user.sentPackets[0] as WrapperPlayServerShowDialog + assertTrue(packet.dialog is NoticeDialog) + + // Notice without action (defaults) + DialogPackets.showSimpleNotice( + player, + "dialog.popup.title", + "dialog.popup.body", + ) + assertEquals(2, user.sentPackets.size) + } + + @Test + fun testWrapPacketEventsUser() { + val user = FakeUser(name = "WrappedUser") + val wrapped = DialogPackets.wrapPacketEventsUser(user) + + assertEquals(user.uuid, wrapped.uniqueId) + assertEquals("WrappedUser", wrapped.name) + assertEquals(Locale.US, wrapped.clientLocale) + assertFalse(wrapped.isFirstJoin) + assertEquals(TriState.NOT_SET, wrapped.isNativeAdmin) + assertEquals(user, wrapped.asPacketEventsUser()) + assertNotNull(wrapped.asAudience()) + assertEquals(TriState.NOT_SET, wrapped.asPermissionChecker().value("some.perm")) + + assertFalse(user.closedConnection) + wrapped.kick(Component.text("Reason")) + assertTrue(user.closedConnection) + } +} diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/DialogSessionManagerTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/DialogSessionManagerTest.kt new file mode 100644 index 0000000..9e214cf --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/DialogSessionManagerTest.kt @@ -0,0 +1,113 @@ +package com.codingcat.changelogs.base.dialog + +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.github.retrooper.packetevents.protocol.dialog.Dialog +import com.github.retrooper.packetevents.protocol.nbt.NBTCompound +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.permission.PermissionChecker +import net.kyori.adventure.text.Component +import net.kyori.adventure.util.TriState +import java.util.* +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DialogSessionManagerTest { + private class DummyDialog(override val id: String) : PluginDialog { + override fun build(player: PlatformPlayer, sessionManager: DialogSessionManager): Dialog = + throw UnsupportedOperationException() + + override fun onActionTriggered( + action: String, + data: NBTCompound?, + source: PlatformPlayer, + sessionManager: DialogSessionManager, + ) { + } + } + + private class TestPlayer(override val uniqueId: UUID = UUID.randomUUID()) : PlatformPlayer { + override val name: String = "TestPlayer" + override val clientLocale: Locale = Locale.US + override val isFirstJoin: Boolean = false + override val isNativeAdmin: TriState = TriState.NOT_SET + override fun asAudience(): Audience = Audience.empty() + override fun asPermissionChecker(): PermissionChecker = PermissionChecker { TriState.TRUE } + override fun kick(reason: Component?) {} + override fun asPacketEventsUser(): Any = Unit + } + + @Test + fun testSessionLifecycle() { + val player = TestPlayer() + val dialog = DummyDialog("test_dialog") + + assertFalse(DialogSessionManager.isSessionActive(player, dialog)) + + DialogSessionManager.startSession(dialog, player, "SessionData123") + assertTrue(DialogSessionManager.isSessionActive(player, dialog)) + assertEquals("SessionData123", DialogSessionManager.getSessionData(player, String::class.java)) + + DialogSessionManager.setSessionData(player, "UpdatedData") + assertEquals("UpdatedData", DialogSessionManager.getSessionData(player, String::class.java)) + + // startSessionIfNoneActive should be a no-op since session is already active + DialogSessionManager.startSessionIfNoneActive(dialog, player) { "Ignored" } + assertEquals("UpdatedData", DialogSessionManager.getSessionData(player, String::class.java)) + + DialogSessionManager.endSession(player) + assertFalse(DialogSessionManager.isSessionActive(player, dialog)) + } + + @Test + fun testFreezeAndUnfreeze() { + val player = TestPlayer() + + assertFalse(DialogSessionManager.isFrozen(player)) + + DialogSessionManager.freeze(player) + assertTrue(DialogSessionManager.isFrozen(player)) + + DialogSessionManager.unfreeze(player) + assertFalse(DialogSessionManager.isFrozen(player)) + } + + @Test + fun testAwaitUnfreezeAsync() { + runBlocking { + val player = TestPlayer() + DialogSessionManager.freeze(player) + + val job = async { + DialogSessionManager.awaitUnfreeze(player, timeoutSeconds = 2) + } + + DialogSessionManager.unfreeze(player) + job.await() + assertFalse(DialogSessionManager.isFrozen(player)) + } + } + + @Test + fun testWaitForUnfreezeBlocking() { + val player = TestPlayer() + DialogSessionManager.freeze(player) + DialogSessionManager.unfreeze(player) + DialogSessionManager.waitForUnfreeze(player, timeoutSeconds = 1) + assertFalse(DialogSessionManager.isFrozen(player)) + } + + @Test + fun testActionAndClickEventCreation() { + val dialog = DummyDialog("sample") + + val staticClick = DialogSessionManager.createStaticClickEvent(dialog, "click") + assertTrue(staticClick.payload().toString().contains("dialog/sample/click")) + + val sessionClick = DialogSessionManager.createSessionBasedClickEvent(dialog, "btn") + assertTrue(sessionClick.payload().toString().contains("dialog/sample/btn")) + } +} diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/PluginDialogTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/PluginDialogTest.kt new file mode 100644 index 0000000..5ce284c --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/PluginDialogTest.kt @@ -0,0 +1,50 @@ +package com.codingcat.changelogs.base.dialog + +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.github.retrooper.packetevents.protocol.dialog.Dialog +import com.github.retrooper.packetevents.protocol.nbt.NBTCompound +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class PluginDialogTest { + private class SimpleDialog(override val id: String) : PluginDialog { + var destroyed = false + var shouldReject = false + + override fun build(player: PlatformPlayer, sessionManager: DialogSessionManager): Dialog { + throw UnsupportedOperationException() + } + + override fun onActionTriggered( + action: String, + data: NBTCompound?, + source: PlatformPlayer, + sessionManager: DialogSessionManager, + ) { + } + + override fun attemptDestroy() { + if (shouldReject) throw PluginDialog.DestroyRejectedException("busy") + destroyed = true + } + } + + @Test + fun testDestroyRejectedException() { + val ex = PluginDialog.DestroyRejectedException("active_editor") + assertEquals("active_editor", ex.key) + } + + @Test + fun testAttemptDestroyOnSimpleDialog() { + val dialog = SimpleDialog("test") + dialog.attemptDestroy() + assertEquals(true, dialog.destroyed) + + dialog.shouldReject = true + assertFailsWith { + dialog.attemptDestroy() + } + } +} diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialogTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialogTest.kt new file mode 100644 index 0000000..fcc920f --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialogTest.kt @@ -0,0 +1,289 @@ +package com.codingcat.changelogs.base.dialog.ui + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.base.TestPacketEvents +import com.codingcat.changelogs.base.compat.PacketEventsNativeItemManager +import com.codingcat.changelogs.base.data.ChangelogEntry +import com.codingcat.changelogs.base.data.ChangelogStorage +import com.codingcat.changelogs.base.dialog.DialogPackets +import com.codingcat.changelogs.base.dialog.DialogSessionManager +import com.codingcat.changelogs.platformapi.ChangelogsPlatform +import com.codingcat.changelogs.platformapi.command.PlatformCommandManager +import com.codingcat.changelogs.platformapi.command.PlatformCommandSource +import com.codingcat.changelogs.platformapi.event.PlatformEventManager +import com.codingcat.changelogs.platformapi.item.NativeItemManager +import com.codingcat.changelogs.platformapi.meta.ChangelogsMeta +import com.codingcat.changelogs.platformapi.meta.PlatformMeta +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.codingcat.changelogs.platformapi.player.PlatformPlayerManager +import com.github.retrooper.packetevents.protocol.ConnectionState +import com.github.retrooper.packetevents.protocol.item.ItemStack +import com.github.retrooper.packetevents.protocol.item.type.ItemTypes +import com.github.retrooper.packetevents.protocol.nbt.NBTCompound +import com.github.retrooper.packetevents.protocol.nbt.NBTInt +import com.github.retrooper.packetevents.protocol.player.ClientVersion +import com.github.retrooper.packetevents.protocol.player.User +import com.github.retrooper.packetevents.protocol.player.UserProfile +import com.github.retrooper.packetevents.wrapper.PacketWrapper +import com.mojang.brigadier.tree.LiteralCommandNode +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.permission.PermissionChecker +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.logger.slf4j.ComponentLogger +import net.kyori.adventure.util.TriState +import java.nio.file.Files +import java.nio.file.Path +import java.time.Instant +import java.time.format.DateTimeFormatter +import java.util.* +import kotlin.test.* + +class ChangelogDialogTest { + private lateinit var tempDir: Path + private val sentPackets = mutableListOf>() + private val sentMessages = mutableListOf() + + private class TestUser( + uuid: UUID, + name: String, + val packetConsumer: (PacketWrapper<*>) -> Unit, + ) : User(null, ConnectionState.PLAY, ClientVersion.V_1_21, UserProfile(uuid, name)) { + override fun sendPacket(packet: PacketWrapper<*>) { + packetConsumer(packet) + } + } + + private class TestPlayer( + val user: TestUser, + val messageConsumer: (Component) -> Unit, + override val name: String = user.name ?: "Admin", + override val isNativeAdmin: TriState = TriState.TRUE, + ) : PlatformPlayer { + override val uniqueId: UUID = user.uuid + override val clientLocale: Locale = Locale.US + override val isFirstJoin: Boolean = false + override fun asAudience(): Audience = object : Audience { + override fun sendMessage(message: Component) { + messageConsumer(message) + } + } + + override fun asPermissionChecker(): PermissionChecker = PermissionChecker { TriState.TRUE } + override fun kick(reason: Component?) {} + override fun asPacketEventsUser(): Any = user + } + + private class MemoryStorage : ChangelogStorage { + val entries = mutableListOf() + override val displayName: String = "Memory" + override fun init() {} + override fun shutdown() {} + override fun nextUID(): Int = (entries.maxOfOrNull { it.uid } ?: 0) + 1 + override fun getByUID(uid: Int): ChangelogEntry? = entries.find { it.uid == uid } + override fun listEntries(): List = entries.toList() + override fun iterator(): Iterator = entries.iterator() + override fun storeEntry(entry: ChangelogEntry) { + entries.removeAll { it.uid == entry.uid } + entries.add(entry) + } + + override fun updateEntry(entry: ChangelogEntry) { + storeEntry(entry) + } + + override fun removeEntry(uid: Int): Boolean = entries.removeAll { it.uid == uid } + override fun markAllAsRead(uids: Collection, player: UUID) { + uids.forEach { uid -> + getByUID(uid)?.playersRead?.add(player) + } + } + } + + private class TestPlatform( + val dir: Path, + val testPlayer: TestPlayer, + ) : ChangelogsPlatform { + override val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined) + override val nativeItemManager: NativeItemManager = PacketEventsNativeItemManager + override val playerManager: PlatformPlayerManager = object : PlatformPlayerManager { + override val onlinePlayers: ImmutableSet = + setOf(testPlayer).toPersistentSet() + + override fun getFromName(name: String): PlatformPlayer? = if (name == testPlayer.name) testPlayer else null + override fun getFromUUID(uuid: UUID): PlatformPlayer? = + if (uuid == testPlayer.uniqueId) testPlayer else null + + override fun fromNative(nativePlayer: Any): PlatformPlayer = testPlayer + } + override val commandManager: PlatformCommandManager = object : PlatformCommandManager { + override fun register(node: LiteralCommandNode<*>, aliases: ImmutableSet) {} + override fun adaptPlatformSource(platformCommandSource: Any): PlatformCommandSource = + platformCommandSource as PlatformCommandSource + } + override val eventManager: PlatformEventManager = object : PlatformEventManager { + override fun registerListener( + eventCls: Class, + listener: (T) -> Unit, + ) { + } + + override fun unregisterListener(listener: Any) {} + override fun unregisterIf(listenerPredicate: (Any) -> Boolean) {} + override fun unregisterAll() {} + } + override val platformMeta: PlatformMeta = object : PlatformMeta { + override val name: Component = Component.text("TestServer") + override val version: String = "1.0.0" + } + override val changelogsMeta: ChangelogsMeta = object : ChangelogsMeta { + override val name: String = "ServerChangelogs" + override val version: String = "1.0.0" + override val description: String = "Desc" + override val authors: Set = setOf("Author") + } + + override fun getComponentLogger(): ComponentLogger = ComponentLogger.logger("Test") + override fun getDataPath(): Path = dir + } + + @BeforeTest + fun setUp() { + TestPacketEvents.setup() + tempDir = Files.createTempDirectory("changelog_dialog_test") + } + + @AfterTest + fun tearDown() { + tempDir.toFile().deleteRecursively() + } + + @Test + fun testBuildEmptyAndNonEmptyDialog() = runBlocking { + val user = TestUser(UUID.randomUUID(), "Alex") { sentPackets.add(it) } + val player = TestPlayer(user, { sentMessages.add(it) }) + val platform = TestPlatform(tempDir, player) + ServerChangelogs.platform = platform + ServerChangelogs.onStart() + + val memoryStorage = MemoryStorage() + val headerItem = ItemStack.builder().type(ItemTypes.PAPER).build() + val dialog = ChangelogDialog( + storage = memoryStorage, + holder = ServerChangelogs.dialogHolder, + dateFormatter = DateTimeFormatter.ISO_INSTANT, + addHeader = true, + headerItem = headerItem, + useFallbackPermissions = true, + ) + + // Empty dialog build + val sessionManager = DialogSessionManager + val emptyBuilt = dialog.build(player, sessionManager) + assertNotNull(emptyBuilt) + + // Non-empty dialog with unread entry + val entry = ChangelogEntry( + uid = 1, + lines = listOf(Component.text("Bug fix 1"), Component.text("Feature 2")), + recordedAt = Instant.now(), + author = Component.text("Dev"), + playersRead = mutableSetOf(), + ) + memoryStorage.storeEntry(entry) + + val builtWithEntry = dialog.build(player, sessionManager) + assertNotNull(builtWithEntry) + + // Non-empty dialog without header + val dialogNoHeader = ChangelogDialog( + storage = memoryStorage, + holder = ServerChangelogs.dialogHolder, + dateFormatter = DateTimeFormatter.ISO_INSTANT, + addHeader = false, + headerItem = null, + useFallbackPermissions = true, + ) + val builtNoHeader = dialogNoHeader.build(player, sessionManager) + assertNotNull(builtNoHeader) + + // showDialogView + dialog.showDialogView(player, sessionManager, DialogPackets.PacketPhase.PLAY) + assertTrue(sentPackets.isNotEmpty()) + + ServerChangelogs.onShutdown() + } + + @Test + fun testActionsTriggered() = runBlocking { + val user = TestUser(UUID.randomUUID(), "Alex") { sentPackets.add(it) } + val player = TestPlayer(user, { sentMessages.add(it) }) + val platform = TestPlatform(tempDir, player) + ServerChangelogs.platform = platform + ServerChangelogs.onStart() + + val memoryStorage = MemoryStorage() + val entry = ChangelogEntry( + uid = 10, + lines = listOf(Component.text("Line 1")), + recordedAt = Instant.now(), + author = Component.text("Author1"), + playersRead = mutableSetOf(), + ) + memoryStorage.storeEntry(entry) + + val dialog = ChangelogDialog( + storage = memoryStorage, + holder = ServerChangelogs.dialogHolder, + dateFormatter = DateTimeFormatter.ISO_INSTANT, + addHeader = false, + headerItem = null, + useFallbackPermissions = true, + ) + val sessionManager = DialogSessionManager + + // 1. Confirm read + assertFalse(entry.hasRead(player)) + dialog.onActionTriggered("confirm_read", null, player, sessionManager) + assertTrue(entry.hasRead(player)) + assertTrue(sentMessages.isNotEmpty()) + + // 2. Reopen + sentPackets.clear() + dialog.onActionTriggered("reopen", null, player, sessionManager) + assertTrue(sentPackets.isNotEmpty()) + + // 3. Request delete + sentPackets.clear() + val payload = NBTCompound().apply { setTag("uid", NBTInt(10)) } + dialog.onActionTriggered("request_delete", payload, player, sessionManager) + assertTrue(sentPackets.isNotEmpty()) + + // 4. Confirm delete + dialog.onActionTriggered("confirm_delete", payload, player, sessionManager) + assertEquals(null, memoryStorage.getByUID(10)) + + // 5. Start editing + val entry2 = ChangelogEntry( + uid = 20, + lines = listOf(Component.text("Line 2")), + recordedAt = Instant.now(), + author = null, + playersRead = mutableSetOf(), + ) + memoryStorage.storeEntry(entry2) + val payload2 = NBTCompound().apply { setTag("uid", NBTInt(20)) } + dialog.onActionTriggered("start_editing", payload2, player, sessionManager) + + // 6. Test createLinesComponent + val lines = listOf(Component.text("Hello"), Component.text("World")) + val linesComp = ChangelogDialog.createLinesComponent(player, { c, _ -> c }, lines) + assertNotNull(linesComp) + + ServerChangelogs.onShutdown() + } +} diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialogTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialogTest.kt new file mode 100644 index 0000000..532b24c --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialogTest.kt @@ -0,0 +1,386 @@ +package com.codingcat.changelogs.base.dialog.ui.editor + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.base.TestPacketEvents +import com.codingcat.changelogs.base.compat.PacketEventsNativeItemManager +import com.codingcat.changelogs.base.data.ChangelogEntry +import com.codingcat.changelogs.base.data.ChangelogStorage +import com.codingcat.changelogs.base.dialog.DialogSessionManager +import com.codingcat.changelogs.base.dialog.PluginDialog +import com.codingcat.changelogs.platformapi.ChangelogsPlatform +import com.codingcat.changelogs.platformapi.command.PlatformCommandManager +import com.codingcat.changelogs.platformapi.command.PlatformCommandSource +import com.codingcat.changelogs.platformapi.event.PlatformEventManager +import com.codingcat.changelogs.platformapi.item.NativeItemManager +import com.codingcat.changelogs.platformapi.meta.ChangelogsMeta +import com.codingcat.changelogs.platformapi.meta.PlatformMeta +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.codingcat.changelogs.platformapi.player.PlatformPlayerManager +import com.github.retrooper.packetevents.protocol.ConnectionState +import com.github.retrooper.packetevents.protocol.nbt.NBTByte +import com.github.retrooper.packetevents.protocol.nbt.NBTCompound +import com.github.retrooper.packetevents.protocol.nbt.NBTInt +import com.github.retrooper.packetevents.protocol.nbt.NBTString +import com.github.retrooper.packetevents.protocol.player.ClientVersion +import com.github.retrooper.packetevents.protocol.player.User +import com.github.retrooper.packetevents.protocol.player.UserProfile +import com.github.retrooper.packetevents.wrapper.PacketWrapper +import com.mojang.brigadier.tree.LiteralCommandNode +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.permission.PermissionChecker +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.logger.slf4j.ComponentLogger +import net.kyori.adventure.util.TriState +import java.nio.file.Files +import java.nio.file.Path +import java.time.Instant +import java.util.* +import kotlin.test.* + +class ChangelogEditorDialogTest { + private lateinit var tempDir: Path + private val sentPackets = mutableListOf>() + private val sentMessages = mutableListOf() + + private class TestUser( + uuid: UUID, + name: String, + val packetConsumer: (PacketWrapper<*>) -> Unit, + ) : User(null, ConnectionState.PLAY, ClientVersion.V_1_21, UserProfile(uuid, name)) { + override fun sendPacket(packet: PacketWrapper<*>) { + packetConsumer(packet) + } + } + + private class TestPlayer( + val user: TestUser, + val messageConsumer: (Component) -> Unit, + override val name: String = user.name ?: "Admin", + override val isNativeAdmin: TriState = TriState.TRUE, + val permGranted: Boolean = true, + ) : PlatformPlayer { + override val uniqueId: UUID = user.uuid + override val clientLocale: Locale = Locale.US + override val isFirstJoin: Boolean = false + override fun asAudience(): Audience = object : Audience { + override fun sendMessage(message: Component) { + messageConsumer(message) + } + } + + override fun asPermissionChecker(): PermissionChecker = + PermissionChecker { if (permGranted) TriState.TRUE else TriState.FALSE } + + override fun kick(reason: Component?) {} + override fun asPacketEventsUser(): Any = user + } + + private class MemoryStorage : ChangelogStorage { + val entries = mutableListOf() + override val displayName: String = "Memory" + override fun init() {} + override fun shutdown() {} + override fun nextUID(): Int = (entries.maxOfOrNull { it.uid } ?: 0) + 1 + override fun getByUID(uid: Int): ChangelogEntry? = entries.find { it.uid == uid } + override fun listEntries(): List = entries.toList() + override fun iterator(): Iterator = entries.iterator() + override fun storeEntry(entry: ChangelogEntry) { + entries.removeAll { it.uid == entry.uid } + entries.add(entry) + } + + override fun updateEntry(entry: ChangelogEntry) { + storeEntry(entry) + } + + override fun removeEntry(uid: Int): Boolean = entries.removeAll { it.uid == uid } + override fun markAllAsRead(uids: Collection, player: UUID) { + uids.forEach { uid -> + getByUID(uid)?.playersRead?.add(player) + } + } + } + + private class TestPlatform( + val dir: Path, + val testPlayer: TestPlayer, + ) : ChangelogsPlatform { + override val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined) + override val nativeItemManager: NativeItemManager = PacketEventsNativeItemManager + override val playerManager: PlatformPlayerManager = object : PlatformPlayerManager { + override val onlinePlayers: ImmutableSet = + setOf(testPlayer).toPersistentSet() + + override fun getFromName(name: String): PlatformPlayer? = if (name == testPlayer.name) testPlayer else null + override fun getFromUUID(uuid: UUID): PlatformPlayer? = + if (uuid == testPlayer.uniqueId) testPlayer else null + + override fun fromNative(nativePlayer: Any): PlatformPlayer = testPlayer + } + override val commandManager: PlatformCommandManager = object : PlatformCommandManager { + override fun register(node: LiteralCommandNode<*>, aliases: ImmutableSet) {} + override fun adaptPlatformSource(platformCommandSource: Any): PlatformCommandSource = + platformCommandSource as PlatformCommandSource + } + override val eventManager: PlatformEventManager = object : PlatformEventManager { + override fun registerListener( + eventCls: Class, + listener: (T) -> Unit, + ) { + } + + override fun unregisterListener(listener: Any) {} + override fun unregisterIf(listenerPredicate: (Any) -> Boolean) {} + override fun unregisterAll() {} + } + override val platformMeta: PlatformMeta = object : PlatformMeta { + override val name: Component = Component.text("TestServer") + override val version: String = "1.0.0" + } + override val changelogsMeta: ChangelogsMeta = object : ChangelogsMeta { + override val name: String = "ServerChangelogs" + override val version: String = "1.0.0" + override val description: String = "Desc" + override val authors: Set = setOf("Author") + } + + override fun getComponentLogger(): ComponentLogger = ComponentLogger.logger("Test") + override fun getDataPath(): Path = dir + } + + @BeforeTest + fun setUp() { + TestPacketEvents.setup() + tempDir = Files.createTempDirectory("changelog_editor_test") + } + + @AfterTest + fun tearDown() { + tempDir.toFile().deleteRecursively() + } + + @Test + fun testBuildCreateAndEditDialogs() = runBlocking { + val user = TestUser(UUID.randomUUID(), "Admin") { sentPackets.add(it) } + val player = TestPlayer(user, { sentMessages.add(it) }) + val platform = TestPlatform(tempDir, player) + ServerChangelogs.platform = platform + ServerChangelogs.onStart() + + val storage = MemoryStorage() + val editor = ChangelogEditorDialog(storage, useFallbackPermissions = true) + val sessionManager = DialogSessionManager + + // 1. Build Create Session + val createDialog = editor.build(player, sessionManager) + assertNotNull(createDialog) + + // 2. Build Edit Session + val existingEntry = ChangelogEntry( + uid = 5, + lines = listOf(Component.text("Fix issue"), Component.text("New feature")), + recordedAt = Instant.now(), + author = Component.text("Dev"), + playersRead = mutableSetOf(), + ) + storage.storeEntry(existingEntry) + + sessionManager.endSession(player) + val editSession = EditorSession.Edit(existingEntry) + sessionManager.startSession(editor, player, editSession) + + val editDialog = editor.build(player, sessionManager) + assertNotNull(editDialog) + + // 3. Title key and permission check tests + assertEquals("dialog.editor.create.title", ChangelogEditorDialog.titleKey(EditorSession.Create(1))) + assertEquals("dialog.editor.edit.title", ChangelogEditorDialog.titleKey(editSession)) + + assertTrue(ChangelogEditorDialog.permissionCheck("create", player, useFallbackPermissions = true)) + assertTrue(ChangelogEditorDialog.permissionCheck("create", player, useFallbackPermissions = false)) + + val noAdminPlayer = TestPlayer(user, {}, isNativeAdmin = TriState.FALSE, permGranted = false) + assertFalse(ChangelogEditorDialog.permissionCheck("create", noAdminPlayer, useFallbackPermissions = true)) + assertFalse(ChangelogEditorDialog.permissionCheck("create", noAdminPlayer, useFallbackPermissions = false)) + + // 4. Attempt destroy without saved sessions + editor.attemptDestroy() + + ServerChangelogs.onShutdown() + } + + @Test + fun testActionAddEditRemoveLines() = runBlocking { + val user = TestUser(UUID.randomUUID(), "Admin") { sentPackets.add(it) } + val player = TestPlayer(user, { sentMessages.add(it) }) + val platform = TestPlatform(tempDir, player) + ServerChangelogs.platform = platform + ServerChangelogs.onStart() + + val storage = MemoryStorage() + val editor = ChangelogEditorDialog(storage, useFallbackPermissions = true) + val sessionManager = DialogSessionManager + + val session = EditorSession.Create(1) + sessionManager.startSession(editor, player, session) + + // Add empty line -> should show retry dialog + sentPackets.clear() + val emptyData = NBTCompound().apply { + setTag("line", NBTString("")) + setTag("author", NBTString("Admin")) + } + editor.onActionTriggered("add_line", emptyData, player, sessionManager) + assertTrue(sentPackets.isNotEmpty()) + + // Add valid line + val validData1 = NBTCompound().apply { + setTag("line", NBTString("Line 1")) + setTag("author", NBTString("Admin")) + } + editor.onActionTriggered("add_line", validData1, player, sessionManager) + assertEquals(listOf("Line 1"), session.rawLines) + assertEquals("", session.currentLine) + + // Add second line + val validData2 = NBTCompound().apply { + setTag("line", NBTString("Line 2")) + setTag("author", NBTString("Admin")) + } + editor.onActionTriggered("add_line", validData2, player, sessionManager) + assertEquals(listOf("Line 1", "Line 2"), session.rawLines) + + // Start edit line 0 + val target0 = NBTCompound().apply { setTag("target_line", NBTInt(0)) } + editor.onActionTriggered("start_edit_line", target0, player, sessionManager) + assertEquals(0, session.editingLineIndex) + assertEquals("Line 1", session.currentLine) + + // Edit line with new content + val editData = NBTCompound().apply { + setTag("line", NBTString("Line 1 Updated")) + setTag("author", NBTString("Admin")) + } + editor.onActionTriggered("edit_line", editData, player, sessionManager) + assertEquals(listOf("Line 1 Updated", "Line 2"), session.rawLines) + assertEquals(-1, session.editingLineIndex) + + // Start edit line 1 and edit with blank (should remove line) + val target1 = NBTCompound().apply { setTag("target_line", NBTInt(1)) } + editor.onActionTriggered("start_edit_line", target1, player, sessionManager) + assertEquals(1, session.editingLineIndex) + val blankEdit = NBTCompound().apply { + setTag("line", NBTString(" ")) + setTag("author", NBTString("Admin")) + } + editor.onActionTriggered("edit_line", blankEdit, player, sessionManager) + assertEquals(listOf("Line 1 Updated"), session.rawLines) + + // Remove line 0 + editor.onActionTriggered("remove_line", target0, player, sessionManager) + assertTrue(session.rawLines.isEmpty()) + + // Reopen action + sentPackets.clear() + editor.onActionTriggered("reopen", null, player, sessionManager) + assertTrue(sentPackets.isNotEmpty()) + + ServerChangelogs.onShutdown() + } + + @Test + fun testActionCloseAndSaveSession() = runBlocking { + val user = TestUser(UUID.randomUUID(), "Admin") { sentPackets.add(it) } + val player = TestPlayer(user, { sentMessages.add(it) }) + val platform = TestPlatform(tempDir, player) + ServerChangelogs.platform = platform + ServerChangelogs.onStart() + + val storage = MemoryStorage() + val editor = ChangelogEditorDialog(storage, useFallbackPermissions = true) + val sessionManager = DialogSessionManager + + // Empty session try_close -> closes immediately + val emptySession = EditorSession.Create(1) + sessionManager.startSession(editor, player, emptySession) + editor.onActionTriggered("try_close", null, player, sessionManager) + assertFalse(sessionManager.isSessionActive(player, editor)) + + // Session with lines try_close -> shows confirm close dialog + val activeSession = EditorSession.Create(2).apply { rawLines.add("Draft line") } + sessionManager.startSession(editor, player, activeSession) + sentPackets.clear() + editor.onActionTriggered("try_close", null, player, sessionManager) + assertTrue(sentPackets.isNotEmpty()) + + // Close with save_session = true + val savePayload = NBTCompound().apply { setTag("save_session", NBTByte(true)) } + editor.onActionTriggered("close", savePayload, player, sessionManager) + assertFalse(sessionManager.isSessionActive(player, editor)) + + // Attempt destroy should now fail because a session was saved + assertFailsWith { + editor.attemptDestroy() + } + + // Reopen dialog -> should restore saved session with restored message + val restoredDialog = editor.build(player, sessionManager) + assertNotNull(restoredDialog) + + // Close with save_session = false -> discarded + val discardPayload = NBTCompound().apply { setTag("save_session", NBTByte(false)) } + editor.onActionTriggered("close", discardPayload, player, sessionManager) + editor.attemptDestroy() // Now passes + + ServerChangelogs.onShutdown() + } + + @Test + fun testActionCommit() = runBlocking { + val user = TestUser(UUID.randomUUID(), "Admin") { sentPackets.add(it) } + val player = TestPlayer(user, { sentMessages.add(it) }) + val platform = TestPlatform(tempDir, player) + ServerChangelogs.platform = platform + ServerChangelogs.onStart() + + val storage = MemoryStorage() + val editor = ChangelogEditorDialog(storage, useFallbackPermissions = true) + val sessionManager = DialogSessionManager + + // Commit with no lines -> retry notice + val session = EditorSession.Create(10) + sessionManager.startSession(editor, player, session) + sentPackets.clear() + val commitData = NBTCompound().apply { + setTag("line", NBTString("")) + setTag("author", NBTString("DevAdmin")) + } + editor.onActionTriggered("commit", commitData, player, sessionManager) + assertTrue(sentPackets.isNotEmpty()) + + // Commit with valid lines -> success + session.rawLines.add("Feature A implemented") + session.rawLines.add("Fixed bug B") + sentPackets.clear() + editor.onActionTriggered("commit", commitData, player, sessionManager) + + // Verify storage received entry + val stored = storage.getByUID(10) + assertNotNull(stored) + assertEquals(2, stored.lines.size) + + // Commit without permission -> rejection + val noPermPlayer = TestPlayer(user, {}, isNativeAdmin = TriState.FALSE, permGranted = false) + val noPermSession = EditorSession.Create(20).apply { rawLines.add("Line") } + sessionManager.startSession(editor, noPermPlayer, noPermSession) + editor.onActionTriggered("commit", commitData, noPermPlayer, sessionManager) + assertEquals(null, storage.getByUID(20)) + + ServerChangelogs.onShutdown() + } +} diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/EditorSessionTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/EditorSessionTest.kt new file mode 100644 index 0000000..c3795a5 --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/EditorSessionTest.kt @@ -0,0 +1,133 @@ +package com.codingcat.changelogs.base.dialog.ui.editor + +import com.codingcat.changelogs.base.data.ChangelogEntry +import com.codingcat.changelogs.base.data.ChangelogStorage +import kotlinx.coroutines.runBlocking +import net.kyori.adventure.text.Component +import java.time.Instant +import java.util.* +import kotlin.test.* + +class EditorSessionTest { + private class InMemoryStorage : ChangelogStorage { + val entries = mutableMapOf() + var shouldFail = false + + override val displayName: String = "InMemory" + override fun init() {} + override fun shutdown() {} + override fun storeEntry(entry: ChangelogEntry) { + if (shouldFail) throw RuntimeException("Storage failure") + entries[entry.uid] = entry + } + + override fun updateEntry(entry: ChangelogEntry) { + if (shouldFail) throw RuntimeException("Storage failure") + entries[entry.uid] = entry + } + + override fun removeEntry(uid: Int): Boolean = entries.remove(uid) != null + override fun listEntries(): List = entries.values.toList() + override fun getByUID(uid: Int): ChangelogEntry? = entries[uid] + override fun markAllAsRead(uids: Collection, player: UUID) {} + override fun markAsRead(uid: Int, player: UUID) {} + override fun nextUID(): Int = (entries.keys.maxOrNull() ?: 0) + 1 + } + + @Test + fun testCreateSession() { + val session = EditorSession.Create(entryUID = 10) + assertEquals("create", session.id) + assertEquals("command.create", session.permission) + assertFalse(session.canBeSaved()) + + session.author = "Author" + session.rawLines.add("Line 1") + assertTrue(session.canBeSaved()) + + assertEquals(1, session.deserializeLines().size) + assertNotNull(session.deserializeAuthor()) + + val storage = InMemoryStorage() + session.commit(storage) + + val stored = storage.getByUID(10) + assertNotNull(stored) + assertEquals(10, stored.uid) + + // Test commit error + storage.shouldFail = true + val ex = assertFailsWith { + session.commit(storage) + } + assertEquals("internal_error", ex.translationKeyPart) + } + + @Test + fun testCreateSessionAsync() { + runBlocking { + val session = EditorSession.Create(entryUID = 11) + session.author = "AsyncAuthor" + session.rawLines.add("AsyncLine") + + val storage = InMemoryStorage() + session.commitAsync(storage) + assertNotNull(storage.getByUID(11)) + } + } + + @Test + fun testEditSession() { + val original = ChangelogEntry( + uid = 20, + lines = listOf(Component.text("Old line")), + recordedAt = Instant.now(), + author = Component.text("OldAuthor"), + playersRead = mutableSetOf(UUID.randomUUID()), + ) + val storage = InMemoryStorage() + storage.storeEntry(original) + + val session = EditorSession.Edit(original) + assertEquals("edit", session.id) + assertEquals("manage", session.permission) + assertFalse(session.canBeSaved()) + assertEquals(1, session.rawLines.size) + + session.rawLines.clear() + session.rawLines.add("New line") + session.commit(storage) + + val updated = storage.getByUID(20) + assertNotNull(updated) + assertEquals(1, updated.playersRead.size) + + // Test commit when entry was deleted from storage + storage.removeEntry(20) + val exDeleted = assertFailsWith { + session.commit(storage) + } + assertEquals("entry_deleted", exDeleted.translationKeyPart) + } + + @Test + fun testEditSessionAsync() { + runBlocking { + val original = ChangelogEntry( + uid = 30, + lines = listOf(Component.text("AsyncOld")), + recordedAt = Instant.now(), + author = null, + playersRead = mutableSetOf(), + ) + val storage = InMemoryStorage() + storage.storeEntry(original) + + val session = EditorSession.Edit(original) + session.rawLines.add("AsyncNew") + session.commitAsync(storage) + + assertNotNull(storage.getByUID(30)) + } + } +} diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/event/ChangelogJoinListenerTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/event/ChangelogJoinListenerTest.kt new file mode 100644 index 0000000..7918277 --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/event/ChangelogJoinListenerTest.kt @@ -0,0 +1,210 @@ +package com.codingcat.changelogs.base.event + +import com.codingcat.changelogs.base.ServerChangelogs +import com.codingcat.changelogs.base.config.PluginConfig +import com.codingcat.changelogs.base.data.ChangelogEntry +import com.codingcat.changelogs.base.data.ChangelogStorage +import com.codingcat.changelogs.base.dialog.DialogSessionManager +import com.codingcat.changelogs.base.dialog.PluginDialog +import com.codingcat.changelogs.base.dialog.ui.ChangelogDialog +import com.codingcat.changelogs.platformapi.ChangelogsPlatform +import com.codingcat.changelogs.platformapi.command.PlatformCommandManager +import com.codingcat.changelogs.platformapi.command.PlatformCommandSource +import com.codingcat.changelogs.platformapi.event.PlatformEventManager +import com.codingcat.changelogs.platformapi.event.impl.PlatformEvent +import com.codingcat.changelogs.platformapi.item.NativeItemManager +import com.codingcat.changelogs.platformapi.meta.ChangelogsMeta +import com.codingcat.changelogs.platformapi.meta.PlatformMeta +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import com.codingcat.changelogs.platformapi.player.PlatformPlayerManager +import com.github.retrooper.packetevents.protocol.dialog.Dialog +import com.github.retrooper.packetevents.protocol.item.ItemStack +import com.github.retrooper.packetevents.protocol.nbt.NBTCompound +import com.mojang.brigadier.tree.LiteralCommandNode +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.key.Key +import net.kyori.adventure.permission.PermissionChecker +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.text.logger.slf4j.ComponentLogger +import net.kyori.adventure.util.TriState +import java.nio.file.Files +import java.nio.file.Path +import java.time.Instant +import java.util.* +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ChangelogJoinListenerTest { + private class DummyPlayer( + override val uniqueId: UUID = UUID.randomUUID(), + override val isFirstJoin: Boolean = false, + ) : PlatformPlayer { + var shownDialog: Dialog? = null + + override val name: String = "TestPlayer" + override val clientLocale: Locale = Locale.US + override val isNativeAdmin: TriState = TriState.NOT_SET + override fun asAudience(): Audience = Audience.empty() + override fun asPermissionChecker(): PermissionChecker = PermissionChecker { TriState.TRUE } + override fun kick(reason: Component?) {} + override fun asPacketEventsUser(): Any = Unit + } + + private class TestDialog( + storage: ChangelogStorage, + ) : ChangelogDialog( + storage = storage, + holder = PluginDialog.Holder, + dateFormatter = java.time.format.DateTimeFormatter.ISO_INSTANT, + addHeader = false, + headerItem = null, + useFallbackPermissions = false, + ) { + override val id: String = "changelog_view" + var showCount = 0 + + override fun showTo( + player: PlatformPlayer, + sessionManager: DialogSessionManager, + packetPhase: com.codingcat.changelogs.base.dialog.DialogPackets.PacketPhase, + ) { + showCount++ + } + + override fun onActionTriggered( + action: String, + data: NBTCompound?, + source: PlatformPlayer, + sessionManager: DialogSessionManager, + ) { + } + } + + private class TestStorage(val entries: List) : ChangelogStorage { + override val displayName: String = "MockStorage" + override fun init() {} + override fun shutdown() {} + override fun storeEntry(entry: ChangelogEntry) {} + override fun updateEntry(entry: ChangelogEntry) {} + override fun removeEntry(uid: Int): Boolean = false + override fun listEntries(): List = entries + override fun getByUID(uid: Int): ChangelogEntry? = entries.find { it.uid == uid } + override fun markAllAsRead(uids: Collection, player: UUID) {} + override fun markAsRead(uid: Int, player: UUID) {} + override fun nextUID(): Int = 1 + } + + private class TestPlatform(val dataDir: Path) : ChangelogsPlatform { + val registeredListeners = mutableListOf>() + + override fun getComponentLogger(): ComponentLogger = ComponentLogger.logger("Test") + override fun getDataPath(): Path = dataDir + override val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined) + override val playerManager: PlatformPlayerManager = object : PlatformPlayerManager { + override val onlinePlayers: ImmutableSet = emptySet().toPersistentSet() + override fun getFromName(name: String): PlatformPlayer? = null + override fun getFromUUID(uuid: UUID): PlatformPlayer? = null + override fun fromNative(nativePlayer: Any): PlatformPlayer = throw UnsupportedOperationException() + } + override val eventManager: PlatformEventManager = object : PlatformEventManager { + override fun registerListener(eventCls: Class, listener: (T) -> Unit) { + registeredListeners.add(eventCls) + } + + override fun unregisterListener(listener: Any) {} + override fun unregisterIf(listenerPredicate: (Any) -> Boolean) { + registeredListeners.clear() + } + + override fun unregisterAll() { + registeredListeners.clear() + } + } + override val commandManager: PlatformCommandManager = object : PlatformCommandManager { + override fun register(node: LiteralCommandNode<*>, aliases: ImmutableSet) {} + override fun adaptPlatformSource(platformCommandSource: Any): PlatformCommandSource = + throw UnsupportedOperationException() + } + override val nativeItemManager: NativeItemManager = object : NativeItemManager { + override fun adaptToPEStack(nativeStack: Any): ItemStack = throw UnsupportedOperationException() + override fun createNativeStack(key: Key, amount: Int): Any? = null + override fun applyComponentStr(nativeStack: Any, componentStr: String): Any = nativeStack + } + override val platformMeta: PlatformMeta = object : PlatformMeta { + override val name: Component = Component.text("MockServer", NamedTextColor.GREEN) + override val version: String = "1.0" + } + override val changelogsMeta: ChangelogsMeta = object : ChangelogsMeta { + override val name: String = "ServerChangelogs" + override val version: String = "1.0" + override val description: String = "Desc" + override val authors: Set = setOf("Author") + } + } + + @Test + fun testCheckShowDialog() { + val tempDir = Files.createTempDirectory("join_listener_test") + val configFile = tempDir.resolve("config.yml") + configFile.writeText( + """ + dialog_phase: "PLAY" + use_native_fallback_permissions: false + """.trimIndent(), + ) + + val platform = TestPlatform(tempDir) + ServerChangelogs.platform = platform + ServerChangelogs.setConfig(PluginConfig(configFile).also { it.reload() }) + + val entry = ChangelogEntry( + uid = 1, + lines = listOf(Component.text("Update 1")), + recordedAt = Instant.now(), + author = null, + playersRead = mutableSetOf(), + ) + val storage = TestStorage(listOf(entry)) + val storageField = ServerChangelogs::class.java.getDeclaredField("changelogStorage") + storageField.isAccessible = true + storageField.set(ServerChangelogs, storage) + + // Inject custom dialog into Holder + val holderField = PluginDialog.Holder::class.java.getDeclaredField("dialogMap") + holderField.isAccessible = true + @Suppress("UNCHECKED_CAST") + val dialogMap = holderField.get(PluginDialog.Holder) as MutableMap + val testDialog = TestDialog(storage) + dialogMap[testDialog.id] = testDialog + + // Player on first join should NOT show dialog + val firstJoinPlayer = DummyPlayer(isFirstJoin = true) + assertFalse(ChangelogJoinListener.checkShowDialog(firstJoinPlayer, freeze = false)) + + // Normal player with unread changelogs should show dialog + val normalPlayer = DummyPlayer(isFirstJoin = false) + assertTrue(ChangelogJoinListener.checkShowDialog(normalPlayer, freeze = true)) + assertTrue(DialogSessionManager.isFrozen(normalPlayer)) + DialogSessionManager.unfreeze(normalPlayer) + + // Player who already read all changelogs should NOT show dialog + entry.playersRead.add(normalPlayer.uniqueId) + assertFalse(ChangelogJoinListener.checkShowDialog(normalPlayer, freeze = false)) + + // Register and unregister events + ChangelogJoinListener.registerEvents(platform.eventManager) + assertTrue(platform.registeredListeners.isNotEmpty()) + + ChangelogJoinListener.unregisterEvents(platform.eventManager) + assertTrue(platform.registeredListeners.isEmpty()) + + tempDir.toFile().deleteRecursively() + } +} diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/lang/TranslationSourceTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/lang/TranslationSourceTest.kt new file mode 100644 index 0000000..4b621dc --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/lang/TranslationSourceTest.kt @@ -0,0 +1,112 @@ +package com.codingcat.changelogs.base.lang + +import com.codingcat.changelogs.platformapi.meta.ChangelogsMeta +import com.codingcat.changelogs.platformapi.meta.PlatformMeta +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import kotlinx.coroutines.runBlocking +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.permission.PermissionChecker +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.translation.GlobalTranslator +import net.kyori.adventure.util.TriState +import org.slf4j.LoggerFactory +import org.slf4j.kotlin.KLogger +import java.nio.file.Files +import java.util.* +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertNotNull + +class TranslationSourceTest { + private val logger = KLogger(LoggerFactory.getLogger("TranslationSourceTest")) + + private class TestChangelogsMeta : ChangelogsMeta { + override val name: String = "TestPlugin" + override val version: String = "1.0.0" + override val description: String = "A test plugin" + override val authors: Set = setOf("AuthorA", "AuthorB") + } + + private class TestPlatformMeta : PlatformMeta { + override val name: Component = Component.text("TestServer", NamedTextColor.AQUA) + override val version: String = "1.21.4" + } + + private class TestPlayer : PlatformPlayer { + override val uniqueId: UUID = UUID.randomUUID() + override val name: String = "TestPlayer" + override val clientLocale: Locale = Locale.US + override val isFirstJoin: Boolean = false + override val isNativeAdmin: TriState = TriState.NOT_SET + override fun asAudience(): Audience = Audience.empty() + override fun asPermissionChecker(): PermissionChecker = PermissionChecker { TriState.TRUE } + override fun kick(reason: Component?) {} + override fun asPacketEventsUser(): Any = Unit + } + + @Test + fun testNonExistentAndEmptyDirectoryHandling() { + val tempDir = Files.createTempDirectory("lang_test_empty") + val nonExistent = tempDir.resolve("missing_lang") + val source1 = TranslationSource(nonExistent, TestChangelogsMeta(), TestPlatformMeta(), logger) + source1.reload() // Should not throw + + val emptyDir = tempDir.resolve("empty_lang").createDirectories() + val source2 = TranslationSource(emptyDir, TestChangelogsMeta(), TestPlatformMeta(), logger) + source2.reload() // Should not throw + + tempDir.toFile().deleteRecursively() + } + + @Test + fun testLoadAndTranslate() { + val tempDir = Files.createTempDirectory("lang_test_valid") + val langFile = tempDir.resolve("en_us.yml") + langFile.writeText( + """ + prefix: "[Changelogs] " + greeting: "Hello world!" + info: + plugin_info: " v by " + server_info: "Running on " + trans_tag: "" + meta: + unknown: "Unknown" + """.trimIndent(), + ) + + val source = TranslationSource(tempDir, TestChangelogsMeta(), TestPlatformMeta(), logger) + source.reload() + + val renderedGreeting = GlobalTranslator.render( + TranslationSource.translatable("greeting"), + Locale.US, + ) + assertNotNull(renderedGreeting) + + val player = TestPlayer() + val manual = TranslationSource.translatableManual(player, "greeting") + assertNotNull(manual) + + val pluginInfo = GlobalTranslator.render( + TranslationSource.translatable("info.plugin_info"), + Locale.US, + ) + assertNotNull(pluginInfo) + + val serverInfo = GlobalTranslator.render( + TranslationSource.translatable("info.server_info"), + Locale.US, + ) + assertNotNull(serverInfo) + + // Test reloadAsync + runBlocking { + source.reloadAsync() + } + + tempDir.toFile().deleteRecursively() + } +} diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/util/ResourceUtilTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/util/ResourceUtilTest.kt new file mode 100644 index 0000000..4d8208d --- /dev/null +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/util/ResourceUtilTest.kt @@ -0,0 +1,34 @@ +package com.codingcat.changelogs.base.util + +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class ResourceUtilTest { + @Test + fun testReadResourceAsString() { + val content = ResourceUtil.readResourceAsString("defaults/config.yml") + assertTrue(content.contains("changelog_storage")) + } + + @Test + fun testReadNonExistentResourceThrows() { + assertFailsWith { + ResourceUtil.readResourceAsString("non_existent_resource_file_xyz.txt") + } + } + + @Test + fun testListResources() { + val list = ResourceUtil.listResources("lang") + assertTrue(list.isNotEmpty()) + assertTrue(list.any { it.endsWith(".yml") }) + } + + @Test + fun testReadResourcesAsString() { + val map = ResourceUtil.readResourcesAsString("lang") + assertTrue(map.isNotEmpty()) + assertTrue(map.containsKey("en-US.yml") || map.keys.any { it.endsWith(".yml") }) + } +} diff --git a/src/platformapi/build.gradle.kts b/src/platformapi/build.gradle.kts index 6bc94df..1a7f380 100644 --- a/src/platformapi/build.gradle.kts +++ b/src/platformapi/build.gradle.kts @@ -10,4 +10,6 @@ dependencies { compileOnly(libs.mojang.brigadier) compileOnly(libs.slf4j.api) testImplementation(libs.kotlin.test) + testImplementation(libs.bundles.adventure) + testImplementation(libs.mojang.brigadier) } diff --git a/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/audience/PermissionAudienceWrapperTest.kt b/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/audience/PermissionAudienceWrapperTest.kt new file mode 100644 index 0000000..c2455bf --- /dev/null +++ b/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/audience/PermissionAudienceWrapperTest.kt @@ -0,0 +1,53 @@ +package com.codingcat.changelogs.platformapi.audience + +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.permission.PermissionChecker +import net.kyori.adventure.util.TriState +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class PermissionAudienceWrapperTest { + private class TestWrapper( + private val permissions: Map, + ) : PermissionAudienceWrapper { + private val audience = Audience.empty() + private val checker = PermissionChecker { permission -> + permissions[permission] ?: TriState.NOT_SET + } + + override fun asAudience(): Audience = audience + override fun asPermissionChecker(): PermissionChecker = checker + } + + @Test + fun testPermissionEvaluation() { + val wrapper = TestWrapper( + mapOf( + "test.granted" to TriState.TRUE, + "test.denied" to TriState.FALSE, + ), + ) + + assertTrue(wrapper.hasPermission("test.granted")) + assertFalse(wrapper.hasPermission("test.denied")) + assertFalse(wrapper.hasPermission("test.unset")) + + assertTrue("test.granted" in wrapper) + assertFalse("test.denied" in wrapper) + assertFalse("test.unset" in wrapper) + + assertTrue(wrapper.hasPermission("test.unset", trueIfUnset = true)) + assertFalse(wrapper.hasPermission("test.unset", trueIfUnset = false)) + assertFalse(wrapper.hasPermission("test.denied", trueIfUnset = true)) + assertTrue(wrapper.hasPermission("test.granted", trueIfUnset = true)) + } + + @Test + fun testAudienceAndCheckerAccessors() { + val wrapper = TestWrapper(emptyMap()) + assertSame(wrapper.asAudience(), wrapper.asAudience()) + assertSame(wrapper.asPermissionChecker(), wrapper.asPermissionChecker()) + } +} diff --git a/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/PlatformEventManagerTest.kt b/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/PlatformEventManagerTest.kt new file mode 100644 index 0000000..68abd29 --- /dev/null +++ b/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/PlatformEventManagerTest.kt @@ -0,0 +1,87 @@ +package com.codingcat.changelogs.platformapi.event + +import com.codingcat.changelogs.platformapi.event.impl.PlatformEvent +import com.codingcat.changelogs.platformapi.event.util.MethodDispatchingListener +import net.kyori.adventure.key.Key +import kotlin.test.Test +import kotlin.test.assertEquals + +class PlatformEventManagerTest { + private class DummyEvent(val num: Int) : PlatformEvent + + private class SampleListener : MethodDispatchingListener { + var callCount = 0 + + @MethodDispatchingListener.ListenerMethod + fun onEvent(event: DummyEvent) { + callCount += event.num + } + } + + private class SimpleEventManager : PlatformEventManager { + val registered = mutableListOf, (Any) -> Unit>>() + + @Suppress("UNCHECKED_CAST") + override fun registerListener(eventCls: Class, listener: (T) -> Unit) { + registered.add(eventCls to (listener as (Any) -> Unit)) + } + + override fun unregisterListener(listener: Any) { + registered.removeAll { it.second == listener } + } + + override fun unregisterIf(listenerPredicate: (Any) -> Boolean) { + registered.removeAll { listenerPredicate(it.second) } + } + + override fun unregisterAll() { + registered.clear() + } + + fun dispatch(event: PlatformEvent) { + registered.filter { it.first.isAssignableFrom(event.javaClass) }.forEach { + it.second(event) + } + } + } + + @Test + fun testIdentifiedListenerRegistrationAndUnregistration() { + val manager = SimpleEventManager() + val key1 = Key.key("test", "listener_one") + val key2 = Key.key("test", "listener_two") + + var count1 = 0 + var count2 = 0 + + manager.registerIdentified(DummyEvent::class.java, key1) { count1++ } + manager.registerIdentified(key2) { count2++ } + + assertEquals(2, manager.registered.size) + manager.dispatch(DummyEvent(1)) + assertEquals(1, count1) + assertEquals(1, count2) + + manager.unregisterIdentified(key1) + assertEquals(1, manager.registered.size) + + manager.dispatch(DummyEvent(1)) + assertEquals(1, count1) + assertEquals(2, count2) + + manager.unregisterAll() + assertEquals(0, manager.registered.size) + } + + @Test + fun testRegisterMethodDispatcher() { + val manager = SimpleEventManager() + val listener = SampleListener() + + manager.registerMethodDispatcher(listener) + assertEquals(1, manager.registered.size) + + manager.dispatch(DummyEvent(5)) + assertEquals(5, listener.callCount) + } +} diff --git a/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/impl/PlatformEventTest.kt b/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/impl/PlatformEventTest.kt new file mode 100644 index 0000000..0b31d00 --- /dev/null +++ b/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/impl/PlatformEventTest.kt @@ -0,0 +1,43 @@ +package com.codingcat.changelogs.platformapi.event.impl + +import com.codingcat.changelogs.platformapi.player.PlatformPlayer +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.permission.PermissionChecker +import net.kyori.adventure.text.Component +import net.kyori.adventure.util.TriState +import java.util.* +import kotlin.test.Test +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class PlatformEventTest { + private class FakePlatformPlayer( + override val uniqueId: UUID = UUID.randomUUID(), + override val name: String = "TestPlayer", + ) : PlatformPlayer { + override val clientLocale: Locale = Locale.US + override val isFirstJoin: Boolean = false + override val isNativeAdmin: TriState = TriState.TRUE + + override fun asAudience(): Audience = Audience.empty() + override fun asPermissionChecker(): PermissionChecker = PermissionChecker { TriState.TRUE } + override fun kick(reason: Component?) {} + override fun asPacketEventsUser(): Any = "packetEventsUser" + } + + @Test + fun testPlayerJoinEvent() { + val player = FakePlatformPlayer() + val event = PlayerJoinEvent(player, justConnected = true) + assertSame(player, event.player) + assertTrue(event.justConnected) + } + + @Test + fun testPlayerEnterConfigurationPhaseEvent() { + val player = FakePlatformPlayer() + val event = PlayerEnterConfigurationPhaseEvent(player, initialConfiguration = true) + assertSame(player, event.player) + assertTrue(event.initialConfiguration) + } +} diff --git a/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/util/MethodDispatchingListenerTest.kt b/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/util/MethodDispatchingListenerTest.kt new file mode 100644 index 0000000..eb6cf4b --- /dev/null +++ b/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/util/MethodDispatchingListenerTest.kt @@ -0,0 +1,125 @@ +package com.codingcat.changelogs.platformapi.event.util + +import com.codingcat.changelogs.platformapi.event.PlatformEventManager +import com.codingcat.changelogs.platformapi.event.impl.PlatformEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class MethodDispatchingListenerTest { + private class DummyEvent(val message: String) : PlatformEvent + + private class AnotherDummyEvent(val count: Int) : PlatformEvent + + private class SimpleTestListener : MethodDispatchingListener { + val received = mutableListOf() + + @MethodDispatchingListener.ListenerMethod + fun onDummy(event: DummyEvent) { + received.add(event.message) + } + + @MethodDispatchingListener.ListenerMethod + fun onAnother(event: AnotherDummyEvent) { + received.add("count:${event.count}") + } + + fun regularMethod() { + // Ignored because no annotation + } + } + + private class InvalidParamsCountListener : MethodDispatchingListener { + @MethodDispatchingListener.ListenerMethod + fun invalidMethod(event: DummyEvent, extra: String) { + } + } + + private class InvalidParamTypeListener : MethodDispatchingListener { + @MethodDispatchingListener.ListenerMethod + fun invalidType(event: String) { + } + } + + private class ThrowingListener : MethodDispatchingListener { + @MethodDispatchingListener.ListenerMethod + fun onThrow(event: DummyEvent) { + throw IllegalStateException("Failure in listener") + } + } + + private class MockPlatformEventManager : PlatformEventManager { + val listeners = mutableListOf, (Any) -> Unit>>() + + @Suppress("UNCHECKED_CAST") + override fun registerListener(eventCls: Class, listener: (T) -> Unit) { + listeners.add(eventCls to (listener as (Any) -> Unit)) + } + + override fun unregisterListener(listener: Any) { + listeners.removeAll { it.second == listener } + } + + override fun unregisterIf(listenerPredicate: (Any) -> Boolean) { + listeners.removeAll { listenerPredicate(it.second) } + } + + override fun unregisterAll() { + listeners.clear() + } + + fun dispatch(event: PlatformEvent) { + listeners.filter { it.first.isAssignableFrom(event.javaClass) }.forEach { + it.second(event) + } + } + } + + @Test + fun testSetupAllAndDispatch() { + val manager = MockPlatformEventManager() + val listener = SimpleTestListener() + + listener.setupAll(manager) + assertEquals(2, manager.listeners.size) + + manager.dispatch(DummyEvent("hello")) + manager.dispatch(AnotherDummyEvent(42)) + + assertEquals(listOf("hello", "count:42"), listener.received) + + listener.unregisterAll(manager) + assertEquals(0, manager.listeners.size) + } + + @Test + fun testInvalidParameterCountThrows() { + val manager = MockPlatformEventManager() + val listener = InvalidParamsCountListener() + assertFailsWith { + listener.setupAll(manager) + } + } + + @Test + fun testInvalidParameterTypeThrows() { + val manager = MockPlatformEventManager() + val listener = InvalidParamTypeListener() + assertFailsWith { + listener.setupAll(manager) + } + } + + @Test + fun testThrowingListenerWrapsException() { + val manager = MockPlatformEventManager() + val listener = ThrowingListener() + listener.setupAll(manager) + + val ex = assertFailsWith { + manager.dispatch(DummyEvent("boom")) + } + assertTrue(ex.cause is IllegalStateException) + } +} diff --git a/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappingsTest.kt b/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappingsTest.kt new file mode 100644 index 0000000..5178f02 --- /dev/null +++ b/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappingsTest.kt @@ -0,0 +1,50 @@ +package com.codingcat.changelogs.platformapi.event.util + +import com.codingcat.changelogs.platformapi.event.impl.PlatformEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class PlatformEventMappingsTest { + private class NativeTestEvent(val text: String) + + private class PlatformTestEvent(val message: String) : PlatformEvent + + private class TestEventMappings : PlatformEventMappings() { + init { + register( + PlatformTestEvent::class.java, + NativeTestEvent::class.java, + ) { native -> + PlatformTestEvent("wrapped:${native.text}") + } + } + } + + private class UnmappedEvent : PlatformEvent + + @Test + fun testEventMappingAndConversion() { + val mappings = TestEventMappings() + val mapping = mappings.findForPlatformEvent(PlatformTestEvent::class.java) + + assertEquals(PlatformTestEvent::class.java, mapping.platformEventCls) + assertEquals(NativeTestEvent::class.java, mapping.nativeEventCls) + + val nativeEvent = NativeTestEvent("hello") + + @Suppress("UNCHECKED_CAST") + val converted = + (mapping as PlatformEventMappings.Mapping).convertToPlatform(nativeEvent) + + assertEquals("wrapped:hello", converted.message) + } + + @Test + fun testUnmappedEventThrows() { + val mappings = TestEventMappings() + assertFailsWith { + mappings.findForPlatformEvent(UnmappedEvent::class.java) + } + } +} From 3601e03cc424cf904d3b149211be876056f2299c Mon Sep 17 00:00:00 2001 From: "GuavaDealer (Idan Nehama)" Date: Mon, 28 Sep 2026 19:48:39 +0300 Subject: [PATCH 5/8] update .idea/.gitignore Signed-off-by: GuavaDealer (Idan Nehama) --- .idea/.gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.idea/.gitignore b/.idea/.gitignore index 49689eb..388f865 100644 --- a/.idea/.gitignore +++ b/.idea/.gitignore @@ -8,3 +8,8 @@ /dbnavigator.xml /discord.xml /jarRepositories.xml +/.name +/gradle.xml +/misc.xml +/modules.xml +/vcs.xml From fafed58ef44e8ac506d91b950120e1b995bf94c6 Mon Sep 17 00:00:00 2001 From: "GuavaDealer (Idan Nehama)" Date: Tue, 29 Sep 2026 01:43:38 +0300 Subject: [PATCH 6/8] feat(build, config): enhance plugin setup and default storage configuration - Set `changelog_storage` default to `sqlite` in `config.yml` for improved reliability and performance. - Add JVM arguments and downloadable Modrinth plugins (`packetevents`, `luckperms`) to `build.gradle.kts` for Velocity and Paper, simplifying local testing. - Adjust `@Subscribe` annotation to `PostOrder.LATE` in `VelocityChangelogsPlatform` for greater event handling accuracy during initialization. - Suppress `VulnerableLibrariesLocal` warnings in build scripts to maintain clean Gradle builds. Signed-off-by: GuavaDealer (Idan Nehama) --- build.gradle.kts | 2 +- src/base/build.gradle.kts | 1 + src/base/src/main/resources/defaults/config.yml | 2 +- src/paper/build.gradle.kts | 5 +++++ src/platformapi/build.gradle.kts | 1 + src/velocity/build.gradle.kts | 5 +++++ .../changelogs/velocity/VelocityChangelogsPlatform.kt | 3 ++- 7 files changed, 16 insertions(+), 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 1fb6933..64efe1b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,7 +45,7 @@ subprojects { shadowJar { mergeServiceFiles() archiveFileName = "${rootProject.name}-${project.name}-${project.version}.jar" - destinationDirectory.set(rootProject.layout.projectDirectory.dir("final")) + destinationDirectory = rootProject.layout.projectDirectory.dir("final") mustRunAfter(cleanFinalArtifacts) duplicatesStrategy = DuplicatesStrategy.INCLUDE } diff --git a/src/base/build.gradle.kts b/src/base/build.gradle.kts index bbf259d..0738441 100644 --- a/src/base/build.gradle.kts +++ b/src/base/build.gradle.kts @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.kotlin.serialization) } +@Suppress("VulnerableLibrariesLocal") dependencies { implementation(projects.platformapi) api(libs.slf4k) diff --git a/src/base/src/main/resources/defaults/config.yml b/src/base/src/main/resources/defaults/config.yml index 9aa4fd4..0a4c127 100644 --- a/src/base/src/main/resources/defaults/config.yml +++ b/src/base/src/main/resources/defaults/config.yml @@ -11,7 +11,7 @@ # - "sqlite": Stores changelogs in a local SQLite database file via JetBrains Exposed. # - "mysql", "mariadb", "postgresql", "h2": Connects to a relational database via JetBrains Exposed. # - "exposed" / "jdbc": Generic Exposed database storage configured below. -changelog_storage: yaml +changelog_storage: sqlite # Database connection settings when using JetBrains Exposed (sqlite, mysql, mariadb, postgresql, h2, exposed). database: diff --git a/src/paper/build.gradle.kts b/src/paper/build.gradle.kts index eb6096f..e265b52 100644 --- a/src/paper/build.gradle.kts +++ b/src/paper/build.gradle.kts @@ -9,6 +9,7 @@ repositories { maven("https://repo.papermc.io/repository/maven-public/") } +@Suppress("VulnerableLibrariesLocal") dependencies { implementation(projects.base) implementation(projects.platformapi) @@ -34,5 +35,9 @@ tasks { runServer { minecraftVersion(project.property("minecraft_version").toString()) jvmArgs("-Xms1G", "-Xmx1G") + downloadPlugins { + modrinth(id = "packetevents", version = "m78nFxYg") + modrinth(id = "luckperms", version = "b0mk8uS6") + } } } diff --git a/src/platformapi/build.gradle.kts b/src/platformapi/build.gradle.kts index 1a7f380..5015e8b 100644 --- a/src/platformapi/build.gradle.kts +++ b/src/platformapi/build.gradle.kts @@ -2,6 +2,7 @@ plugins { `java-library` } +@Suppress("VulnerableLibrariesLocal", "RedundantSuppression") dependencies { api(libs.kotlinx.immutable) api(libs.kotlinx.coroutines.core) diff --git a/src/velocity/build.gradle.kts b/src/velocity/build.gradle.kts index eda4619..7a653f8 100644 --- a/src/velocity/build.gradle.kts +++ b/src/velocity/build.gradle.kts @@ -35,5 +35,10 @@ velocityPluginJson { tasks { runVelocity { velocityVersion(libs.versions.velocity.api.get()) + jvmArgs("-Xms512M", "-Xmx512M") + downloadPlugins { + modrinth(id = "packetevents", version = "p0asH9aC") + modrinth(id = "luckperms", version = "tamnmXad") + } } } diff --git a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/VelocityChangelogsPlatform.kt b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/VelocityChangelogsPlatform.kt index 07e030b..97a693d 100644 --- a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/VelocityChangelogsPlatform.kt +++ b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/VelocityChangelogsPlatform.kt @@ -14,6 +14,7 @@ import com.github.retrooper.packetevents.PacketEventsAPI import com.github.shynixn.mccoroutine.velocity.SuspendingPluginContainer import com.github.shynixn.mccoroutine.velocity.scope import com.google.inject.Inject +import com.velocitypowered.api.event.PostOrder import com.velocitypowered.api.event.Subscribe import com.velocitypowered.api.event.proxy.ProxyInitializeEvent import com.velocitypowered.api.event.proxy.ProxyPreShutdownEvent @@ -72,7 +73,7 @@ class VelocityChangelogsPlatform @Inject constructor( private var earlyInitSucceeded = false - @Subscribe + @Subscribe(order = PostOrder.LATE) suspend fun onProxyInitialization(event: ProxyInitializeEvent) { runCatching { Class.forName("com.github.retrooper.packetevents.PacketEvents") From 39d4b680153acd10077dfde90fbae5de45e5d138 Mon Sep 17 00:00:00 2001 From: "GuavaDealer (Idan Nehama)" Date: Tue, 29 Sep 2026 03:27:41 +0300 Subject: [PATCH 7/8] refactor: apply codestyle conventions and add comprehensive kdoc - Add comprehensive KDoc documentation across core, platform API, Paper, Velocity, and build extension components. - Standardize Kotlin member and scope references using explicit this and labeled this@... receivers. - Adopt idiomatic Kotlin collection factories like hashSetOf in place of raw Java collection constructors. - Reformat long expressions, JDBC connection URLs, method arguments, and comment blocks to conform to line length limits. - Modernize default configuration and localization of YAML files with header banners, clean scalar formatting, and block scalars for multiline strings. - Pin Paper API dependency to a fixed stable release rather than a dynamic build selector. Signed-off-by: GuavaDealer (Idan Nehama) --- buildSrc/src/main/kotlin/BuildExtensions.kt | 18 +++++++++ gradle/libs.versions.toml | 2 +- .../changelogs/base/command/CommandBuilder.kt | 1 - .../base/command/ServerChangelogsCommand.kt | 20 +++++++--- .../changelogs/base/config/PluginConfig.kt | 7 +++- .../data/storage/ExposedChangelogStorage.kt | 6 ++- .../base/dialog/ui/ChangelogDialog.kt | 8 +++- .../dialog/ui/editor/ChangelogEditorDialog.kt | 23 +++++++++-- .../base/dialog/ui/editor/EditorSession.kt | 39 +++++++++++++++++++ .../changelogs/base/lang/TranslationSource.kt | 16 ++++++-- .../changelogs/base/util/ResourceUtil.kt | 22 +++++++++-- .../src/main/resources/defaults/config.yml | 19 +++++---- src/base/src/main/resources/lang/en_US.yml | 23 +++++++++-- src/base/src/main/resources/lang/ru_RU.yml | 23 +++++++++-- .../data/storage/YamlChangelogStorageTest.kt | 2 +- .../paper/command/PaperCommandManager.kt | 10 ++++- .../paper/event/PaperEventManager.kt | 7 +++- .../paper/player/PaperPlayerManager.kt | 9 +++-- .../platformapi/ChangelogsPlatform.kt | 6 ++- .../audience/PermissionAudienceWrapper.kt | 3 +- .../command/PlatformCommandSource.kt | 3 +- .../event/util/MethodDispatchingListener.kt | 3 +- .../event/util/PlatformEventMappings.kt | 6 ++- .../event/util/PlatformEventMappingsTest.kt | 4 +- .../command/VelocityCommandManager.kt | 9 +++-- .../velocity/event/VelocityEventManager.kt | 15 ++++--- .../velocity/player/VelocityPlayerManager.kt | 9 +++-- 27 files changed, 241 insertions(+), 72 deletions(-) diff --git a/buildSrc/src/main/kotlin/BuildExtensions.kt b/buildSrc/src/main/kotlin/BuildExtensions.kt index 7669e49..f2ee62f 100644 --- a/buildSrc/src/main/kotlin/BuildExtensions.kt +++ b/buildSrc/src/main/kotlin/BuildExtensions.kt @@ -6,23 +6,41 @@ import org.gradle.api.tasks.TaskContainer import org.gradle.api.tasks.TaskProvider import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension +/** + * Configures the [JavaPluginExtension] on this project. + */ fun Project.java(configure: Action): Unit = extensions.configure("java", configure) +/** + * Configures the [KotlinJvmProjectExtension] on this project. + */ fun Project.kotlin(configure: Action): Unit = extensions.configure("kotlin", configure) +/** + * Configures the shadowJar task when the Shadow plugin is applied. + */ fun Project.shadowJar(configure: Action) { pluginManager.withPlugin("com.gradleup.shadow") { tasks.named("shadowJar", ShadowJar::class.java, configure) } } +/** + * Configures the shadowJar task on this [TaskContainer]. + */ fun TaskContainer.shadowJar(configure: Action): TaskProvider = named("shadowJar", ShadowJar::class.java, configure) +/** + * Throws an [IllegalStateException] indicating that a required project property is missing. + */ fun Project.missingProperty(name: String): Nothing = throw IllegalStateException("Property '${name}' is missing. Please define it in gradle.properties.") +/** + * Throws an [IllegalStateException] indicating that a required property is missing. + */ fun missingProperty(name: String): Nothing = throw IllegalStateException("Property '${name}' is missing. Please define it in gradle.properties.") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef51af8..a4e585e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ kotlinx-immutable = "0.5.2" adventure = "5.2.0" mojang-brigadier = "1.3.10" packetevents = "2.14.0" -paper-api = "26.1.2.build.+" +paper-api = "26.1.2.build.74-stable" resource-factory = "1.3.1" run-task = "3.1.0" shadow = "9.6.1" diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/CommandBuilder.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/CommandBuilder.kt index 8404c9b..f4eb385 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/CommandBuilder.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/CommandBuilder.kt @@ -39,4 +39,3 @@ interface CommandBuilder : BrigadierCommandNode { fun PlatformCommandManager.register(builder: CommandBuilder, plugin: ServerChangelogs) { this.register(builder.build(plugin), builder.aliases.toPersistentSet()) } - diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/ServerChangelogsCommand.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/ServerChangelogsCommand.kt index 9890d94..8dffcdf 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/ServerChangelogsCommand.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/ServerChangelogsCommand.kt @@ -35,27 +35,35 @@ object ServerChangelogsCommand : AbstractCommand( builder.literal("create") { requiresPermission("command.create", false) executesPlayer { player, _ -> - plugin.dialogHolder.getFromType() - .showTo(player, plugin.dialogSessionManager, DialogPackets.PacketPhase.PLAY) + this@ServerChangelogsCommand.plugin.dialogHolder.getFromType() + .showTo( + player, + this@ServerChangelogsCommand.plugin.dialogSessionManager, + DialogPackets.PacketPhase.PLAY, + ) } } builder.literal("view") { requiresPermission("command.view", true) executesPlayer { player, _ -> - plugin.dialogHolder.getFromType() - .showTo(player, plugin.dialogSessionManager, DialogPackets.PacketPhase.PLAY) + this@ServerChangelogsCommand.plugin.dialogHolder.getFromType() + .showTo( + player, + this@ServerChangelogsCommand.plugin.dialogSessionManager, + DialogPackets.PacketPhase.PLAY, + ) } } builder.literal("reload") { requiresPermission("command.reload") executesSourceSuspend { source, _ -> - executeReload(source, false) + this@ServerChangelogsCommand.executeReload(source, false) } literal("--force") { executesSourceSuspend { source, _ -> - executeReload(source, true) + this@ServerChangelogsCommand.executeReload(source, true) } } } diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt index 130dccc..f877e9d 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt @@ -88,16 +88,19 @@ class PluginConfig( * Asynchronously attempts to reload and validate configuration on Dispatchers.IO. */ suspend fun tryReloadAsync() = withContext(Dispatchers.IO) { - tryReload() + this@PluginConfig.tryReload() } /** * Asynchronously reloads configuration from disk on Dispatchers.IO. */ suspend fun reloadAsync() = withContext(Dispatchers.IO) { - reload() + this@PluginConfig.reload() } + /** + * Synchronously reads and deserializes the configuration file from disk. + */ fun reload() { val text = this.path.readText() this.data = if (text.isBlank()) { diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/ExposedChangelogStorage.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/ExposedChangelogStorage.kt index bf01947..2df7356 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/ExposedChangelogStorage.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/ExposedChangelogStorage.kt @@ -323,11 +323,13 @@ class ExposedChangelogStorage( } "mysql" -> { - "jdbc:mysql://${config.host}:${config.port}/${config.database}?useSSL=false&allowPublicKeyRetrieval=true" + "jdbc:mysql://${config.host}:${config.port}/${config.database}" + + "?useSSL=false&allowPublicKeyRetrieval=true" } "mariadb" -> { - "jdbc:mariadb://${config.host}:${config.port}/${config.database}?useSSL=false&allowPublicKeyRetrieval=true" + "jdbc:mariadb://${config.host}:${config.port}/${config.database}" + + "?useSSL=false&allowPublicKeyRetrieval=true" } "postgresql", "postgres" -> { diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.kt index a9bb83b..b826598 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.kt @@ -34,6 +34,9 @@ private const val HEADER_ITEM_WIDTH: Int = 17 private const val HEADER_ITEM_HEIGHT: Int = 17 private const val CLOSE_BUTTON_WIDTH: Int = 60 +/** + * Dialog UI presenting published server changelogs to players. + */ open class ChangelogDialog( private val storage: ChangelogStorage, private val holder: PluginDialog.Holder, @@ -44,6 +47,9 @@ open class ChangelogDialog( ) : PluginDialog { override val id: String = "changelog_view" + /** + * Shows the changelog view dialog to the specified player during the given packet phase. + */ open fun showDialogView( player: PlatformPlayer, sessionManager: DialogSessionManager, @@ -90,7 +96,7 @@ open class ChangelogDialog( ) } else { entries.forEach { entry -> - val formatted = formatEntry(entry, player, sessionManager, canManage) + val formatted = this@ChangelogDialog.formatEntry(entry, player, sessionManager, canManage) bodyList.add( PlainMessageDialogBody( PlainMessage(formatted, LINE_WIDTH), diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.kt index 1d952e5..a66e012 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.kt @@ -50,6 +50,9 @@ private const val CANCEL_BUTTON_WIDTH: Int = 100 private const val EDITOR_DIALOG_COLUMNS: Int = 3 private const val CONFIRM_CLOSE_COLUMNS: Int = 3 +/** + * Interactive dialog-based changelog editor enabling players with permissions to create and edit entries. + */ class ChangelogEditorDialog( private val storage: ChangelogStorage, private val useFallbackPermissions: Boolean, @@ -63,8 +66,8 @@ class ChangelogEditorDialog( sessionManager: DialogSessionManager, ): Dialog { sessionManager.startSessionIfNoneActive(this, player) { - this.savedSessions.remove(player.uniqueId)?.takeIf { it.canBeSaved() } - ?: EditorSession.Create(storage.nextUID()) + this@ChangelogEditorDialog.savedSessions.remove(player.uniqueId)?.takeIf { it.canBeSaved() } + ?: EditorSession.Create(this@ChangelogEditorDialog.storage.nextUID()) } val session: EditorSession = sessionManager.getSessionData(player, EditorSession::class.java) val sessionTranslation = "dialog.editor.${session.id}" @@ -85,7 +88,13 @@ class ChangelogEditorDialog( TranslationSource.translatable("${prefix}edit"), ) .decoration(TextDecoration.BOLD, false) - .clickEvent(sessionManager.createSessionBasedClickEvent(this, "start_edit_line", payload)) + .clickEvent( + sessionManager.createSessionBasedClickEvent( + this@ChangelogEditorDialog, + "start_edit_line", + payload, + ), + ) } val remove: Component = translatableManual( player, @@ -93,7 +102,13 @@ class ChangelogEditorDialog( TranslationSource.translatable("${prefix}remove"), ) .decoration(TextDecoration.BOLD, false) - .clickEvent(sessionManager.createSessionBasedClickEvent(this, "remove_line", payload)) + .clickEvent( + sessionManager.createSessionBasedClickEvent( + this@ChangelogEditorDialog, + "remove_line", + payload, + ), + ) var finalCmp = line.appendSpace() if (!isEditing) edit?.let { finalCmp = finalCmp.append(it).appendSpace() } finalCmp.append(remove) diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.kt index 4a7a3bc..2f6103d 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.kt @@ -6,6 +6,11 @@ import net.kyori.adventure.text.Component import net.kyori.adventure.text.minimessage.MiniMessage import java.time.Instant +/** + * Active editing session representing the drafting or updating of a changelog entry. + * + * @property entryUID Target unique identifier for the changelog entry being edited. + */ sealed class EditorSession( val entryUID: Int, ) { @@ -15,28 +20,56 @@ sealed class EditorSession( var author: String = "" var showRestoredMessage: Boolean = false + /** + * Commits this editor session synchronously to the specified [storage]. + * + * @throws CommitException if saving fails. + */ @Throws(CommitException::class) abstract fun commit(storage: ChangelogStorage) + /** + * Asynchronously commits this editor session to the specified [storage]. + * + * @throws CommitException if saving fails. + */ @Throws(CommitException::class) open suspend fun commitAsync(storage: ChangelogStorage) { commit(storage) } + /** + * Unique session mode identifier used for localization and UI state. + */ abstract val id: String + /** + * Permission node required to commit or interact with this session mode. + */ abstract val permission: String + /** + * Whether this session contains unsaved state eligible for session restoration. + */ abstract fun canBeSaved(): Boolean + /** + * Deserializes raw MiniMessage line strings into [Component] instances. + */ fun deserializeLines(): List { return rawLines.map { MiniMessage.miniMessage().deserialize(it) } } + /** + * Deserializes the author string into a [Component], or `null` if blank. + */ fun deserializeAuthor(): Component? { return if (author.isNotBlank()) MiniMessage.miniMessage().deserialize(author) else null } + /** + * Editor session for authoring a new changelog entry. + */ class Create(entryUID: Int) : EditorSession(entryUID) { override val id: String = "create" override val permission: String = "command.create" @@ -76,6 +109,9 @@ sealed class EditorSession( } } + /** + * Editor session for modifying an existing changelog entry. + */ class Edit(entry: ChangelogEntry) : EditorSession(entry.uid) { init { entry.lines.mapTo(rawLines) { MiniMessage.miniMessage().serialize(it) } @@ -124,5 +160,8 @@ sealed class EditorSession( } } + /** + * Exception raised when an [EditorSession] cannot be committed to storage. + */ class CommitException(val translationKeyPart: String) : Exception() } diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/lang/TranslationSource.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/lang/TranslationSource.kt index 8a5564d..9dac566 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/lang/TranslationSource.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/lang/TranslationSource.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import net.kyori.adventure.key.Key import net.kyori.adventure.text.Component -import net.kyori.adventure.text.Component.text import net.kyori.adventure.text.ComponentLike import net.kyori.adventure.text.TranslatableComponent import net.kyori.adventure.text.minimessage.MiniMessage @@ -28,6 +27,9 @@ import java.nio.file.Path import java.util.* import kotlin.io.path.* +/** + * Manages translation stores and MiniMessage localization bundles loaded from YAML files. + */ class TranslationSource( private val sourceDirectory: Path, private val changelogsMeta: ChangelogsMeta, @@ -46,9 +48,12 @@ class TranslationSource( * Asynchronously reloads all translation files from disk on Dispatchers.IO. */ suspend fun reloadAsync() = withContext(Dispatchers.IO) { - reload() + this@TranslationSource.reload() } + /** + * Synchronously reloads translation stores from disk and registers them to Adventure GlobalTranslator. + */ fun reload() { logger.info { "Reloading translation store..." } if (!sourceDirectory.exists()) { @@ -177,14 +182,14 @@ class TranslationSource( else -> throw context.newException("Invalid argument \"${property}\"", args) } Tag.selfClosingInserting( - value?.let(::text) ?: translatable("meta.unknown"), + value?.let(Component::text) ?: translatable("meta.unknown"), ) } .tag("platform") { args, context -> val property = args.popOr("Missing argument for tag \"platform\"").lowerValue() val value: Component = when (property) { "name" -> platformMeta.name - "version" -> text(platformMeta.version) + "version" -> Component.text(platformMeta.version) else -> throw context.newException("Invalid argument \"${property}\"", args) } Tag.selfClosingInserting(value) @@ -193,6 +198,9 @@ class TranslationSource( } companion object { + /** + * Creates a namespaced [TranslatableComponent] formatted under this plugin's namespace. + */ fun translatable( key: String, vararg args: ComponentLike, diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/util/ResourceUtil.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/util/ResourceUtil.kt index aea2e7f..e904f71 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/util/ResourceUtil.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/util/ResourceUtil.kt @@ -9,25 +9,39 @@ import java.nio.file.Path import kotlin.io.path.listDirectoryEntries import kotlin.io.path.name +/** + * Utility for reading and enumerating classpath resources across filesystem and JAR environments. + */ object ResourceUtil { private val classLoader: ClassLoader = ResourceUtil::class.java.classLoader + /** + * Reads all text resources located under [dirPath] into a map of filename to content. + */ fun readResourcesAsString(dirPath: String): Map = - listResources(dirPath).associateWith { resource -> - readResourceAsString("${dirPath}/${resource}") + this.listResources(dirPath).associateWith { resource -> + this.readResourceAsString("${dirPath}/${resource}") } + /** + * Reads the resource at classpath [path] as a UTF-8 string. + */ fun readResourceAsString(path: String): String = try { - val stream = checkNotNull(classLoader.getResourceAsStream(path)) { "Resource not found: ${path}" } + val stream = checkNotNull(this.classLoader.getResourceAsStream(path)) { "Resource not found: ${path}" } stream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } } catch (e: IOException) { throw RuntimeException("Failed to read jar resource at ${path}", e) } + /** + * Lists the direct child resource entry names under classpath [dirPath]. + */ fun listResources(dirPath: String): Collection { try { - val resourceUrl: URL = checkNotNull(classLoader.getResource(dirPath)) { "Resource not found: ${dirPath}" } + val resourceUrl: URL = checkNotNull(this.classLoader.getResource(dirPath)) { + "Resource not found: ${dirPath}" + } val uri = resourceUrl.toURI() return if (uri.scheme.equals("jar", ignoreCase = true)) { val (fileSystem, shouldClose) = runCatching { FileSystems.getFileSystem(uri) to false } diff --git a/src/base/src/main/resources/defaults/config.yml b/src/base/src/main/resources/defaults/config.yml index 0a4c127..88bfe0c 100644 --- a/src/base/src/main/resources/defaults/config.yml +++ b/src/base/src/main/resources/defaults/config.yml @@ -1,9 +1,8 @@ -# -# ---- ServerChangelogs by codingcat2468 ---- -# Translation files can be found in the "lang" directory. -# The language shown to the player is automatically determined by -# paper/adventure based on the selected language of the client. -# +# ============================================================================== +# ServerChangelogs Configuration +# Defines runtime parameters, storage backends, and UI options. +# Translation files are stored in the "lang" directory. +# ============================================================================== # The changelog storage is used to store all server changelogs. # Available options: @@ -18,13 +17,13 @@ database: # Database engine: sqlite, mysql, mariadb, postgresql, h2 type: sqlite # Hostname for remote databases - host: "localhost" + host: localhost # Port for remote databases (3306 for MySQL/MariaDB, 5432 for PostgreSQL) port: 3306 # Database or schema name - database: "server_changelogs" + database: server_changelogs # Authentication credentials - username: "root" + username: root password: "" # Optional explicit JDBC connection URL (overrides host, port, database if set) # jdbc_url: "jdbc:sqlite:changelogs.db" @@ -43,7 +42,7 @@ date_format: "MM/dd/yyyy" # The timezone in which the time a changelog was published at will be displayed. # This can be a text identifier, such as "Europe/Paris" or a UTC offset specifier like "UTC+1". -date_timezone: "UTC" +date_timezone: UTC # Whether the plugin should register a dedicated /changelog command (not /changelogS). # This can be useful if you want a more intuitive experience for players, diff --git a/src/base/src/main/resources/lang/en_US.yml b/src/base/src/main/resources/lang/en_US.yml index da9eab0..0120a37 100644 --- a/src/base/src/main/resources/lang/en_US.yml +++ b/src/base/src/main/resources/lang/en_US.yml @@ -1,3 +1,8 @@ +# ============================================================================== +# ServerChangelogs Localization: en_US (American English) +# Defines player- and console-facing MiniMessage translation templates. +# ============================================================================== + # The part below makes it so that clicking on the plugin's name anywhere # (including the prefix) will open the modrinth page! If you don't want this, you can uncomment the version # below the current one (remove the #) and comment out the current version (add a # in front of it). @@ -25,7 +30,10 @@ command: # Translations for the plugin's commands details: "Details for unexpected reload error:" invalid: "Invalid configuration: " dialog_reject: - saved_sessions: "There are some saved editor draft sessions left. Note that reloading will delete those drafts! To reload the plugin anyways, click here." + saved_sessions: >- + There are some saved editor draft sessions left. + Note that reloading will delete those drafts! To reload the plugin anyways, + click here. icon: # General icon definitions, this can be used to change icons everywhere while only changing a single value confirm: "" # All icons are textures from the vanilla resource pack (gui atlas) deny: "" @@ -87,10 +95,16 @@ dialog: # Translations for the plugin's dialog UIs Note that saved sessions WILL NOT survive server restarts! error: create: - internal_error: " An internal error occurred while trying to publish your dialog.\n\nMore details have been logged to the server's console." + internal_error: |- + An internal error occurred while trying to publish your dialog. + + More details have been logged to the server's console. edit: entry_deleted: " The changelog entry has been deleted during editing." - internal_error: " An internal error occurred while trying to apply your changes.\n\nMore details have been logged to the server's console." + internal_error: |- + An internal error occurred while trying to apply your changes. + + More details have been logged to the server's console. no_lines: " Please specify at least one changelog line." add_empty_line: " Please specify the content of the line first." no_permission: " You don't have permission to perform this action!" @@ -119,7 +133,8 @@ dialog: # Translations for the plugin's dialog UIs Published by entry_line: "- " # The format for a single changelog line (adds a gray dash in front) - unspecified_author: "Unspecified" # The author that will be shown when this field was left empty during publishing + # The author that will be shown when this field was left empty during publishing + unspecified_author: "Unspecified" button: close: " Exit" read: " Marked entries as read!" diff --git a/src/base/src/main/resources/lang/ru_RU.yml b/src/base/src/main/resources/lang/ru_RU.yml index 690ac85..3a7ab29 100644 --- a/src/base/src/main/resources/lang/ru_RU.yml +++ b/src/base/src/main/resources/lang/ru_RU.yml @@ -1,3 +1,8 @@ +# ============================================================================== +# ServerChangelogs Localization: ru_RU (Russian) +# Defines player- and console-facing MiniMessage translation templates. +# ============================================================================== + # Параметр ниже делает так, что нажатие на название плагина в любом месте # (включая префикс) откроет страницу на Modrinth! Если это не нужно, можно раскомментировать версию # строкой ниже (убрать #) и закомментировать текущую (поставить # перед ней). @@ -25,7 +30,10 @@ command: # Переводы команд плагина details: "Подробности непредвиденной ошибки перезагрузки:" invalid: "Некорректная конфигурация: " dialog_reject: - saved_sessions: "Остались сохранённые черновики редактора. Обратите внимание: перезагрузка удалит эти черновики! Чтобы всё равно перезагрузить плагин, нажмите сюда." + saved_sessions: >- + Остались сохранённые черновики редактора. + Обратите внимание: перезагрузка удалит эти черновики! Чтобы всё равно перезагрузить плагин, + нажмите сюда. icon: # Общие иконки; смена значения здесь меняет иконку везде confirm: "" # Все иконки — это текстуры из ванильного ресурс-пака (атлас gui) deny: "" @@ -87,10 +95,16 @@ dialog: # Переводы диалоговых интерфейсов (UI) пл Учтите, что сохранённые сессии НЕ СОХРАНЯЮТСЯ при перезагрузках сервера! error: create: - internal_error: " Произошла внутренняя ошибка при попытке опубликовать диалог.\n\nПодробности записаны в консоль сервера." + internal_error: |- + Произошла внутренняя ошибка при попытке опубликовать диалог. + + Подробности записаны в консоль сервера. edit: entry_deleted: " Запись списка изменений была удалена во время редактирования." - internal_error: " Произошла внутренняя ошибка при попытке применить изменения.\n\nПодробности записаны в консоль сервера." + internal_error: |- + Произошла внутренняя ошибка при попытке применить изменения. + + Подробности записаны в консоль сервера. no_lines: " Пожалуйста, укажите хотя бы одну строку изменений." add_empty_line: " Сначала введите содержимое строки." no_permission: " У вас недостаточно прав для выполнения этого действия!" @@ -119,7 +133,8 @@ dialog: # Переводы диалоговых интерфейсов (UI) пл Опубликовал: entry_line: "- " # Формат строки (добавляет серый дефис в начале) - unspecified_author: "Не указан" # Автор по умолчанию + # Автор по умолчанию + unspecified_author: "Не указан" button: close: " Выход" read: " Отмечено как прочитанные: !" diff --git a/src/base/src/test/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorageTest.kt b/src/base/src/test/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorageTest.kt index f50a642..25189a3 100644 --- a/src/base/src/test/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorageTest.kt +++ b/src/base/src/test/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorageTest.kt @@ -57,7 +57,7 @@ class YamlChangelogStorageTest { assertEquals(1, storage.listEntries().size) assertEquals( "Updated Line 1", - (storage.getByUID(1)?.lines?.firstOrNull() as? net.kyori.adventure.text.TextComponent)?.content() + (storage.getByUID(1)?.lines?.firstOrNull() as? net.kyori.adventure.text.TextComponent)?.content(), ) storage.markAsRead(1, playerUuid) diff --git a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/command/PaperCommandManager.kt b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/command/PaperCommandManager.kt index e90d9b6..3441510 100644 --- a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/command/PaperCommandManager.kt +++ b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/command/PaperCommandManager.kt @@ -11,13 +11,16 @@ import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents import kotlinx.collections.immutable.ImmutableSet import org.bukkit.plugin.Plugin +/** + * Command manager implementation for Paper platform using Brigadier lifecycle events. + */ @Suppress("UNCHECKED_CAST") object PaperCommandManager : PlatformCommandManager { - private val registrarActions: MutableSet<(Commands) -> Unit> = HashSet() + private val registrarActions: MutableSet<(Commands) -> Unit> = hashSetOf() override fun register(node: LiteralCommandNode<*>, aliases: ImmutableSet) { val commandNode = node as LiteralCommandNode - registrarActions.add { commands: Commands -> commands.register(commandNode, aliases) } + this.registrarActions.add { commands: Commands -> commands.register(commandNode, aliases) } } @Throws(IllegalArgumentException::class) @@ -28,6 +31,9 @@ object PaperCommandManager : PlatformCommandManager { return WrappedCommandSourceStack(platformCommandSource, PaperPlayerManager) } + /** + * Binds queued command registration actions to Paper's lifecycle event manager. + */ fun registerEvents(lifecycleEventManager: LifecycleEventManager) { lifecycleEventManager.registerEventHandler( LifecycleEvents.COMMANDS, diff --git a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/event/PaperEventManager.kt b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/event/PaperEventManager.kt index 9bb0a9e..05f4ff9 100644 --- a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/event/PaperEventManager.kt +++ b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/event/PaperEventManager.kt @@ -9,9 +9,12 @@ import org.bukkit.Bukkit import org.bukkit.event.* import org.bukkit.plugin.EventExecutor +/** + * Event manager implementation adapting Bukkit/Paper event listeners to platform events. + */ @Suppress("UNCHECKED_CAST") object PaperEventManager : PlatformEventManager { - private val listenerSet: MutableSet> = HashSet() + private val listenerSet: MutableSet> = hashSetOf() private val eventMappings: PaperEventMappings = PaperEventMappings(PaperPlayerManager) override fun registerListener(eventCls: Class, listener: (T) -> Unit) { @@ -32,7 +35,7 @@ object PaperEventManager : PlatformEventManager { } override fun unregisterIf(listenerPredicate: (Any) -> Boolean) { - val iterator = listenerSet.iterator() + val iterator = this.listenerSet.iterator() while (iterator.hasNext()) { val mappingListener = iterator.next() if (listenerPredicate(mappingListener.listener)) { diff --git a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/player/PaperPlayerManager.kt b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/player/PaperPlayerManager.kt index 4bc912e..35bf1c0 100644 --- a/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/player/PaperPlayerManager.kt +++ b/src/paper/src/main/kotlin/com/codingcat/changelogs/paper/player/PaperPlayerManager.kt @@ -8,16 +8,19 @@ import org.bukkit.Bukkit import org.bukkit.entity.Player import java.util.* +/** + * Player manager implementation providing Paper [Player] resolution and wrapping. + */ object PaperPlayerManager : PlatformPlayerManager { override val onlinePlayers: ImmutableSet - get() = Bukkit.getOnlinePlayers().map { fromNative(it) }.toImmutableSet() + get() = Bukkit.getOnlinePlayers().map { this.fromNative(it) }.toImmutableSet() override fun getFromUUID(uuid: UUID): PlatformPlayer? { - return Bukkit.getPlayer(uuid)?.let { fromNative(it) } + return Bukkit.getPlayer(uuid)?.let { this.fromNative(it) } } override fun getFromName(name: String): PlatformPlayer? { - return Bukkit.getPlayer(name)?.let { fromNative(it) } + return Bukkit.getPlayer(name)?.let { this.fromNative(it) } } @Throws(IllegalArgumentException::class) diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/ChangelogsPlatform.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/ChangelogsPlatform.kt index 0fef94b..0b1a12a 100644 --- a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/ChangelogsPlatform.kt +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/ChangelogsPlatform.kt @@ -16,13 +16,15 @@ import java.nio.file.Path interface ChangelogsPlatform { /** * Platform logger supporting Adventure component rendering. - * Defined as a method to match Paper's [org.bukkit.plugin.Plugin.getComponentLogger] and avoid JVM accidental override errors. + * Defined as a method to match Paper's [org.bukkit.plugin.Plugin.getComponentLogger] + * and avoid JVM accidental override errors. */ fun getComponentLogger(): ComponentLogger /** * Resolves the root data directory path dedicated to storing plugin configuration and changelogs. - * Defined as a method to match Paper's [org.bukkit.plugin.Plugin.getDataPath] and avoid JVM accidental override errors. + * Defined as a method to match Paper's [org.bukkit.plugin.Plugin.getDataPath] + * and avoid JVM accidental override errors. */ fun getDataPath(): Path diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/audience/PermissionAudienceWrapper.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/audience/PermissionAudienceWrapper.kt index 4019dc4..1eadfda 100644 --- a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/audience/PermissionAudienceWrapper.kt +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/audience/PermissionAudienceWrapper.kt @@ -26,7 +26,8 @@ interface PermissionAudienceWrapper { } /** - * Evaluates permission presence while treating unset ([TriState.NOT_SET]) status as granted if [trueIfUnset] is true. + * Evaluates permission presence while treating unset ([TriState.NOT_SET]) status + * as granted if [trueIfUnset] is true. */ fun hasPermission(permission: String, trueIfUnset: Boolean): Boolean { val value = asPermissionChecker().value(permission) diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/command/PlatformCommandSource.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/command/PlatformCommandSource.kt index b214ff1..70163e9 100644 --- a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/command/PlatformCommandSource.kt +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/command/PlatformCommandSource.kt @@ -4,7 +4,8 @@ import com.codingcat.changelogs.platformapi.audience.PermissionAudienceWrapper import com.codingcat.changelogs.platformapi.player.PlatformPlayer /** - * Abstraction wrapping an invocation source capable of receiving messages, checking permissions, and identifying an optional executing player. + * Abstraction wrapping an invocation source capable of receiving messages, + * checking permissions, and identifying an optional executing player. */ interface PlatformCommandSource : PermissionAudienceWrapper { /** diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/util/MethodDispatchingListener.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/util/MethodDispatchingListener.kt index bda749e..6cab024 100644 --- a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/util/MethodDispatchingListener.kt +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/util/MethodDispatchingListener.kt @@ -5,7 +5,8 @@ import com.codingcat.changelogs.platformapi.event.impl.PlatformEvent import java.lang.reflect.InvocationTargetException /** - * Interface enabling reflective discovery and binding of methods annotated with [ListenerMethod] to a [PlatformEventManager]. + * Interface enabling reflective discovery and binding of methods annotated + * with [ListenerMethod] to a [PlatformEventManager]. */ interface MethodDispatchingListener { /** diff --git a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappings.kt b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappings.kt index ca268ae..0b443d5 100644 --- a/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappings.kt +++ b/src/platformapi/src/main/kotlin/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappings.kt @@ -3,14 +3,16 @@ package com.codingcat.changelogs.platformapi.event.util import com.codingcat.changelogs.platformapi.event.impl.PlatformEvent /** - * Registry holding bidirectional type mappings between platform-neutral [PlatformEvent] types and platform-native events [E]. + * Registry holding bidirectional type mappings between platform-neutral [PlatformEvent] + * types and platform-native events [E]. */ @Suppress("UNCHECKED_CAST") abstract class PlatformEventMappings { private val eventMappings = mutableSetOf>() /** - * Registers a mapping translating native event [nativeEventCls] into platform event [platformEventCls] using [mapper]. + * Registers a mapping translating native event [nativeEventCls] into platform event + * [platformEventCls] using [mapper]. */ protected fun register( platformEventCls: Class, diff --git a/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappingsTest.kt b/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappingsTest.kt index 5178f02..0955c93 100644 --- a/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappingsTest.kt +++ b/src/platformapi/src/test/kotlin/com/codingcat/changelogs/platformapi/event/util/PlatformEventMappingsTest.kt @@ -34,8 +34,8 @@ class PlatformEventMappingsTest { val nativeEvent = NativeTestEvent("hello") @Suppress("UNCHECKED_CAST") - val converted = - (mapping as PlatformEventMappings.Mapping).convertToPlatform(nativeEvent) + val converted = (mapping as PlatformEventMappings.Mapping) + .convertToPlatform(nativeEvent) assertEquals("wrapped:hello", converted.message) } diff --git a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/command/VelocityCommandManager.kt b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/command/VelocityCommandManager.kt index b4147d4..0043046 100644 --- a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/command/VelocityCommandManager.kt +++ b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/command/VelocityCommandManager.kt @@ -11,17 +11,20 @@ import com.velocitypowered.api.command.CommandMeta import com.velocitypowered.api.command.CommandSource import kotlinx.collections.immutable.ImmutableSet +/** + * Command manager implementation adapting Brigadier commands to Velocity's [CommandManager]. + */ @Suppress("UNCHECKED_CAST") object VelocityCommandManager : PlatformCommandManager { lateinit var commandManager: CommandManager lateinit var platform: VelocityChangelogsPlatform override fun register(node: LiteralCommandNode<*>, aliases: ImmutableSet) { val command = BrigadierCommand(node as LiteralCommandNode) - val commandMeta: CommandMeta = commandManager.metaBuilder(command) + val commandMeta: CommandMeta = this.commandManager.metaBuilder(command) .aliases(*aliases.toTypedArray()) - .plugin(platform) + .plugin(this.platform) .build() - commandManager.register(commandMeta, command) + this.commandManager.register(commandMeta, command) } @Throws(IllegalArgumentException::class) diff --git a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/event/VelocityEventManager.kt b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/event/VelocityEventManager.kt index 4ca6cf3..627b0ee 100644 --- a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/event/VelocityEventManager.kt +++ b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/event/VelocityEventManager.kt @@ -9,12 +9,15 @@ import com.velocitypowered.api.event.AwaitingEventExecutor import com.velocitypowered.api.event.EventManager import com.velocitypowered.api.event.EventTask +/** + * Event manager implementation adapting Velocity proxy events to platform events. + */ @Suppress("UNCHECKED_CAST") object VelocityEventManager : PlatformEventManager { lateinit var eventManager: EventManager lateinit var platform: VelocityChangelogsPlatform - private val handlerSet: MutableSet> = HashSet() + private val handlerSet: MutableSet> = hashSetOf() private val eventMappings: VelocityEventMappings = VelocityEventMappings(VelocityPlayerManager) override fun registerListener(eventCls: Class, listener: (T) -> Unit) { @@ -22,7 +25,7 @@ object VelocityEventManager : PlatformEventManager { val mappingHandler = PlatformMappingHandler(listener, mapping) val nativeClass = mapping.nativeEventCls as Class - this.eventManager.register(platform, nativeClass, mappingHandler) + this.eventManager.register(this.platform, nativeClass, mappingHandler) this.handlerSet.add(mappingHandler) } @@ -31,11 +34,11 @@ object VelocityEventManager : PlatformEventManager { } override fun unregisterIf(listenerPredicate: (Any) -> Boolean) { - val iterator = handlerSet.iterator() + val iterator = this.handlerSet.iterator() while (iterator.hasNext()) { val handler = iterator.next() if (listenerPredicate(handler.listener)) { - this.eventManager.unregister(platform, handler) + this.eventManager.unregister(this.platform, handler) iterator.remove() } } @@ -51,12 +54,12 @@ object VelocityEventManager : PlatformEventManager { private val mapping: PlatformEventMappings.Mapping, ) : AwaitingEventExecutor { override fun execute(event: Any) { - executeInternal(event) + this.executeInternal(event) } override fun executeAsync(event: Any): EventTask { return EventTask.async { - executeInternal(event) + this@PlatformMappingHandler.executeInternal(event) } } diff --git a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/player/VelocityPlayerManager.kt b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/player/VelocityPlayerManager.kt index cbd6b3a..e8b3eeb 100644 --- a/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/player/VelocityPlayerManager.kt +++ b/src/velocity/src/main/kotlin/com/codingcat/changelogs/velocity/player/VelocityPlayerManager.kt @@ -9,17 +9,20 @@ import kotlinx.collections.immutable.toImmutableSet import java.util.* import kotlin.jvm.optionals.getOrNull +/** + * Player manager implementation providing Velocity [Player] resolution and wrapping. + */ object VelocityPlayerManager : PlatformPlayerManager { lateinit var server: ProxyServer override val onlinePlayers: ImmutableSet - get() = server.allPlayers.map { fromNative(it) }.toImmutableSet() + get() = this.server.allPlayers.map { this.fromNative(it) }.toImmutableSet() override fun getFromUUID(uuid: UUID): PlatformPlayer? { - return server.getPlayer(uuid).getOrNull()?.let { fromNative(it) } + return this.server.getPlayer(uuid).getOrNull()?.let { this.fromNative(it) } } override fun getFromName(name: String): PlatformPlayer? { - return server.getPlayer(name).getOrNull()?.let { fromNative(it) } + return this.server.getPlayer(name).getOrNull()?.let { this.fromNative(it) } } @Throws(IllegalArgumentException::class) From 49dc8c2ec5a01e9ee5fccf21cfae8928eea28d32 Mon Sep 17 00:00:00 2001 From: "GuavaDealer (Idan Nehama)" Date: Thu, 1 Oct 2026 23:51:21 +0300 Subject: [PATCH 8/8] build: externalize plugin metadata and improve property resolution - Externalize author and contributor lists into Gradle properties and dynamically populate Paper and Velocity plugin manifests. - Standardize the Paper plugin identifier to align with the Velocity module naming convention. - Adopt safe Gradle property lookups with descriptive error handling for required build properties. - Replace fully qualified class references with explicit imports in storage classes to improve code readability. Signed-off-by: GuavaDealer (Idan Nehama) --- gradle.properties | 4 ++++ .../changelogs/base/data/ChangelogStorage.kt | 11 +++++---- .../base/data/storage/YamlChangelogStorage.kt | 7 +++--- src/paper/build.gradle.kts | 23 +++++++++++++++---- src/velocity/build.gradle.kts | 8 +++++-- 5 files changed, 39 insertions(+), 14 deletions(-) diff --git a/gradle.properties b/gradle.properties index 18013b8..8be48ef 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,6 +2,10 @@ group = com.codingcat version = 1.2.1 description = Keep players up to date on server changes using intuitive, dialog-based UIs! url = https://modrinth.com/plugin/server-changelogs +# Comma separated list of authors +authors = codingcat2468 +# Comma separated list of contributors +contributors = codingcat2468,GuavaDealer minecraft_version = 26.1.2 minecraft_version_compat = 26.1 org.gradle.caching = true diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogStorage.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogStorage.kt index 5fab3b0..a936dc3 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogStorage.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogStorage.kt @@ -4,8 +4,11 @@ import com.codingcat.changelogs.base.ServerChangelogs import com.codingcat.changelogs.base.config.PluginConfig import com.codingcat.changelogs.base.data.storage.ExposedChangelogStorage import com.codingcat.changelogs.base.data.storage.YamlChangelogStorage +import org.slf4j.LoggerFactory +import org.slf4j.kotlin.KLogger +import java.nio.file.Path import java.time.Instant -import java.util.UUID +import java.util.* /** * Persistence abstraction for storing, updating, reading, and indexing [ChangelogEntry] items. @@ -164,14 +167,14 @@ interface ChangelogStorage : Iterable { @Throws(IllegalArgumentException::class) fun create( config: PluginConfig, - dataPath: java.nio.file.Path = runCatching { ServerChangelogs.platform.getDataPath() } - .getOrElse { java.nio.file.Path.of(".") }, + dataPath: Path = runCatching { ServerChangelogs.platform.getDataPath() } + .getOrElse { Path.of(".") }, ): ChangelogStorage { return when (val identifier = config.changelogStorageType.lowercase()) { "yaml" -> YamlChangelogStorage(dataPath.resolve("_data.yml")) "sqlite", "h2", "mysql", "mariadb", "postgresql", "postgres", "exposed", "jdbc" -> { val logger = runCatching { ServerChangelogs.logger } - .getOrElse { org.slf4j.kotlin.KLogger(org.slf4j.LoggerFactory.getLogger("ChangelogsStorage")) } + .getOrElse { KLogger(LoggerFactory.getLogger("ChangelogsStorage")) } ExposedChangelogStorage( dataPath = dataPath, config = config.databaseConfig.copy( diff --git a/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.kt index 390f387..65ab3b6 100644 --- a/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.kt +++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.kt @@ -16,6 +16,7 @@ import java.nio.file.StandardCopyOption import java.time.Instant import java.time.format.DateTimeFormatter import java.util.* +import java.util.concurrent.ConcurrentHashMap import kotlin.io.path.exists import kotlin.io.path.readText import kotlin.io.path.writeText @@ -47,7 +48,7 @@ class YamlChangelogStorage( private val lock = Any() private val yaml: Yaml = Yaml(configuration = YamlConfiguration(strictMode = false)) private var cache: MutableList = mutableListOf() - private var playerFirstSeen: MutableMap = java.util.concurrent.ConcurrentHashMap() + private var playerFirstSeen: MutableMap = ConcurrentHashMap() override val displayName: String = "YAML File" @@ -55,7 +56,7 @@ class YamlChangelogStorage( synchronized(lock) { if (!filePath.exists()) { this.cache = mutableListOf() - this.playerFirstSeen = java.util.concurrent.ConcurrentHashMap() + this.playerFirstSeen = ConcurrentHashMap() this.saveLocked() return } @@ -73,7 +74,7 @@ class YamlChangelogStorage( runCatching { UUID.fromString(uuidStr) to DateTimeFormatter.ISO_INSTANT.parse(timeStr, Instant::from) }.getOrNull() - }.toMap(java.util.concurrent.ConcurrentHashMap()) + }.toMap(ConcurrentHashMap()) } catch (e: Exception) { throw RuntimeException("Failed to load changelog data from ${filePath}", e) } diff --git a/src/paper/build.gradle.kts b/src/paper/build.gradle.kts index e265b52..fb3e2f4 100644 --- a/src/paper/build.gradle.kts +++ b/src/paper/build.gradle.kts @@ -21,19 +21,32 @@ dependencies { } paperPluginYaml { - name = rootProject.name + name = "${rootProject.name}-${project.name}" main = "${group}.changelogs.paper.PaperChangelogsPlatform" - website = project.property("url") as String - apiVersion = project.property("minecraft_version_compat").toString() + website = project.findProperty("url")?.toString() + ?: missingProperty("url") + apiVersion = project.findProperty("minecraft_version_compat")?.toString() + ?: missingProperty("minecraft_version_compat") foliaSupported = true - authors.addAll("codingcat2468") + authors.addAll( + project.findProperty("authors")?.toString()?.split(",") + ?: emptyList() + ) + contributors.addAll( + project.findProperty("contributors")?.toString()?.split(",") + ?: emptyList() + ) + dependencies.server.create("packetevents") } tasks { runServer { - minecraftVersion(project.property("minecraft_version").toString()) + minecraftVersion( + project.findProperty("minecraft_version")?.toString() + ?: missingProperty("minecraft_version") + ) jvmArgs("-Xms1G", "-Xmx1G") downloadPlugins { modrinth(id = "packetevents", version = "m78nFxYg") diff --git a/src/velocity/build.gradle.kts b/src/velocity/build.gradle.kts index 7a653f8..b31d8ca 100644 --- a/src/velocity/build.gradle.kts +++ b/src/velocity/build.gradle.kts @@ -25,10 +25,14 @@ dependencies { velocityPluginJson { id = "server-changelogs" name = "${rootProject.name}-${project.name}" - url = project.findProperty("url")?.toString() ?: missingProperty("url") + url = project.findProperty("url")?.toString() + ?: missingProperty("url") main = "${group}.changelogs.velocity.VelocityChangelogsPlatform" - authors.addAll("codingcat2468") + authors.addAll( + project.findProperty("authors")?.toString()?.split(",") + ?: emptyList() + ) dependencies.addAll(VelocityPluginJson.Dependency("packetevents", false)) }