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
new file mode 100644
index 0000000..388f865
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,15 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+/AndroidProjectSystem.xml
+/compiler.xml
+/dbnavigator.xml
+/discord.xml
+/jarRepositories.xml
+/.name
+/gradle.xml
+/misc.xml
+/modules.xml
+/vcs.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/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/README.md b/README.md
index d601c8c..ba23a97 100644
--- a/README.md
+++ b/README.md
@@ -7,16 +7,17 @@
[](https://github.com/codingcat2468/ServerChangelogs)
[](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 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.
+
+| 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:
+
- ``: 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,40 @@ 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!
-- [x] **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
+- [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!
+ 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!
+
+- [x] **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 b1c4203..0000000
--- a/base/build.gradle.kts
+++ /dev/null
@@ -1,22 +0,0 @@
-plugins {
- `java-library`
- alias(libs.plugins.lombok)
-}
-
-dependencies {
- implementation(project(":platformapi"))
- // Since SnakeYAML is included in some platforms, it is included as compileOnly here
- // The library can be declared as implementation and shadowed on platforms where it is not natively included
- 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 extends IDialog> 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 a5c5cbf..0000000
--- a/base/src/main/java/com/codingcat/changelogs/base/data/ChangelogStorage.java
+++ /dev/null
@@ -1,49 +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.time.Instant;
-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);
-
- @Nullable Instant getFirstSeenAt(@NotNull UUID player);
-
- @NotNull Instant recordFirstSeen(@NotNull UUID player, @NotNull Instant seenAt);
-
- default boolean isUnreadFor(@NotNull ChangelogEntry entry, @NotNull UUID player) {
- Instant firstSeenAt = this.getFirstSeenAt(player);
- return firstSeenAt != null
- && entry.recordedAt().isAfter(firstSeenAt)
- && !entry.playersRead().contains(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 a5ed96b..0000000
--- a/base/src/main/java/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.java
+++ /dev/null
@@ -1,221 +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 static final @NotNull DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_INSTANT;
- private final @NotNull Path filePath;
- private List cache;
- private Map playerFirstSeen;
-
- @Override
- public void init() {
- if (!filePath.toFile().exists()) {
- this.cache = new ArrayList<>();
- this.playerFirstSeen = new HashMap<>();
- this.save();
- return;
- }
- try {
- Yaml config = new Yaml();
- Map data = config.load(new FileInputStream(filePath.toFile()));
- this.cache = this.deserializeEntries(data);
- this.playerFirstSeen = this.deserializePlayerFirstSeen(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> entries = this.cache.stream()
- .map(this::serializeEntry)
- .toList();
- Map data = new LinkedHashMap<>();
- data.put("entries", entries);
- data.put("playerFirstSeen", this.serializePlayerFirstSeen());
- try (FileOutputStream os = new FileOutputStream(filePath.toFile())) {
- config.dump(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 deserializePlayerFirstSeen(@NotNull Map config) {
- try {
- //noinspection unchecked
- Map rawFirstSeen = (Map) config.get("playerFirstSeen");
- if (rawFirstSeen == null) return new HashMap<>();
- return rawFirstSeen.entrySet().stream().collect(Collectors.toMap(
- e -> UUID.fromString(e.getKey()),
- e -> DATE_FORMATTER.parse(e.getValue(), Instant::from)
- ));
- } catch (Exception e) {
- throw new RuntimeException("Failed to deserialize player first-seen data", e);
- }
- }
-
- private @NotNull Map serializePlayerFirstSeen() {
- return this.playerFirstSeen.entrySet().stream().collect(Collectors.toMap(
- entry -> entry.getKey().toString(),
- entry -> DATE_FORMATTER.format(entry.getValue())
- ));
- }
-
- 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 = DATE_FORMATTER.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 @Nullable Instant getFirstSeenAt(@NotNull UUID player) {
- return this.playerFirstSeen.get(player);
- }
-
- @Override
- public @NotNull Instant recordFirstSeen(@NotNull UUID player, @NotNull Instant seenAt) {
- Instant previous = this.playerFirstSeen.putIfAbsent(player, seenAt);
- if (previous != null) this.save();
- return Objects.requireNonNullElse(previous, seenAt);
- }
-
- @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 288428a..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" + (this.storage.isUnreadFor(entry, player.getUniqueId()) ? "_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 -> this.storage.isUnreadFor(e, source.getUniqueId()))
- .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 84a6fd5..0000000
--- a/base/src/main/java/com/codingcat/changelogs/base/event/ChangelogJoinListener.java
+++ /dev/null
@@ -1,59 +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.time.Instant;
-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) {
- ChangelogStorage storage = this.storageSupplier.get();
- Instant firstSeenAt = storage.recordFirstSeen(player.getUniqueId(), Instant.now());
- boolean unreadChangelogs = storage.listEntries().stream()
- .anyMatch(e -> !e.hasRead(player) && e.recordedAt().isAfter(firstSeenAt));
- 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..64efe1b 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,7 +1,115 @@
+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")
+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)
+}
+
+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)
+ }
+
+ kotlin {
+ jvmToolchain(javaVersion)
+ }
+
+ shadowJar {
+ mergeServiceFiles()
+ archiveFileName = "${rootProject.name}-${project.name}-${project.version}.jar"
+ destinationDirectory = rootProject.layout.projectDirectory.dir("final")
+ mustRunAfter(cleanFinalArtifacts)
+ duplicatesStrategy = DuplicatesStrategy.INCLUDE
+ }
+
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"))
+ }
+ }
+
+ tasks.withType().configureEach {
+ outputs.upToDateWhen { false }
+ useJUnitPlatform()
+ }
+}
+
+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)
+ }
+ }
+}
+
+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/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..f2ee62f
--- /dev/null
+++ b/buildSrc/src/main/kotlin/BuildExtensions.kt
@@ -0,0 +1,46 @@
+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
+
+/**
+ * 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.properties b/gradle.properties
index bdf2942..8be48ef 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,9 +1,19 @@
-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
+# 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
+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 968f37d..a4e585e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,34 +1,74 @@
[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.+"
+paper-api = "26.1.2.build.74-stable"
resource-factory = "1.3.1"
run-task = "3.1.0"
shadow = "9.6.1"
slf4j = "2.0.18"
-snakeyaml = "2.7"
+slf4k = "0.5.4"
velocity-api = "3.5.0-SNAPSHOT"
+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"
+netty = "4.1.118.Final"
+kover = "0.9.9"
[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" }
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" }
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" }
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" }
+netty-buffer = { module = "io.netty:netty-buffer", version.ref = "netty" }
+
+[bundles]
+adventure = [
+ "adventure-api",
+ "adventure-slf4j",
+ "adventure-minimessage",
+ "adventure-gson",
+]
+exposed = [
+ "exposed-core",
+ "exposed-jdbc",
+ "exposed-dao",
+ "exposed-java-time",
+]
[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" }
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/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/build.gradle.kts b/paper/build.gradle.kts
deleted file mode 100644
index feea179..0000000
--- a/paper/build.gradle.kts
+++ /dev/null
@@ -1,41 +0,0 @@
-plugins {
- java
- alias(libs.plugins.lombok)
- alias(libs.plugins.shadow)
- alias(libs.plugins.run.paper)
- alias(libs.plugins.resource.factory.paper.convention)
-}
-
-repositories {
- mavenCentral()
- maven("https://repo.papermc.io/repository/maven-public/")
-}
-
-dependencies {
- implementation(project(":base"))
- implementation(project(":platformapi"))
- compileOnly(libs.packetevents.paper)
- compileOnly(libs.paper.api)
-}
-
-paperPluginYaml {
- name = rootProject.name
- main = "${group}.changelogs.paper.PaperChangelogsPlatform"
- website = project.property("url") as String
- apiVersion = project.property("minecraft_version_compat").toString()
- foliaSupported = true
-
- authors.addAll("codingcat2468")
- dependencies.server.create("packetevents")
-}
-
-java {
- toolchain.languageVersion = JavaLanguageVersion.of(25)
-}
-
-tasks {
- runServer {
- minecraftVersion(project.property("minecraft_version").toString())
- jvmArgs("-Xms1G", "-Xmx1G")
- }
-}
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 extends MethodDispatchingListener> 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..0738441
--- /dev/null
+++ b/src/base/build.gradle.kts
@@ -0,0 +1,25 @@
+plugins {
+ `java-library`
+ alias(libs.plugins.kotlin.serialization)
+}
+
+@Suppress("VulnerableLibrariesLocal")
+dependencies {
+ implementation(projects.platformapi)
+ 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)
+ 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/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..997e891
--- /dev/null
+++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/ServerChangelogs.kt
@@ -0,0 +1,233 @@
+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 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
+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 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
+ get() = 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 suspend fun onStart() {
+ componentLogger = platform.getComponentLogger()
+ logger = KLogger(componentLogger)
+
+ val translationPath: Path = platform.getDataPath().resolve("lang")
+ 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}\":" }
+ }
+ }
+ }
+ }
+ this.translationSource = TranslationSource(
+ translationPath,
+ platform.changelogsMeta,
+ platform.platformMeta,
+ logger,
+ )
+ this.translationSource.reloadAsync()
+
+ info("console.startup")
+
+ val configPath: Path = platform.getDataPath().resolve("config.yml")
+ 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.tryReloadAsync()
+ this.changelogStorage = this.config.createChangelogStorage()
+ info(
+ "console.startup_storage",
+ Component.text(this.changelogStorage.displayName),
+ )
+ this.changelogStorage.initAsync()
+ 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 IllegalArgumentException if configuration syntax validation fails.
+ * @throws PluginDialog.DestroyRejectedException if an active dialog rejects reload and [force] is false.
+ */
+ @Throws(IOException::class, PluginDialog.DestroyRejectedException::class)
+ suspend fun reload(force: Boolean) {
+ info("console.reload")
+ if (!force) this.dialogHolder.ensureCanReload()
+ this.translationSource.reloadAsync()
+ this.config.reloadAsync()
+ this.config.validate()?.let { err ->
+ throw IllegalArgumentException(err)
+ }
+ this.changelogStorage.shutdownAsync()
+ this.changelogStorage = this.config.createChangelogStorage()
+ info(
+ "console.startup_storage",
+ Component.text(this.changelogStorage.displayName),
+ )
+ this.changelogStorage.initAsync()
+ PacketEventsFix.setManualWorkarounds(config.enabledManualWorkarounds, logger)
+ this.dialogHolder.recreate()
+ val eventManager = platform.eventManager
+ ChangelogJoinListener.unregisterEvents(eventManager)
+ ChangelogJoinListener.registerEvents(eventManager)
+ }
+
+ override suspend fun onShutdown() {
+ info("console.shutdown")
+ this.dialogSessionManager.unregisterEvents()
+ ChangelogJoinListener.unregisterEvents(platform.eventManager)
+ this.changelogStorage.shutdownAsync()
+ }
+
+ /**
+ * Active configuration settings instance.
+ */
+ 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.
+ */
+ 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..56c83c9
--- /dev/null
+++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/AbstractCommand.kt
@@ -0,0 +1,273 @@
+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 kotlinx.coroutines.launch
+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
+ }
+ }
+
+ /**
+ * 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.
+ *
+ * @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..f4eb385
--- /dev/null
+++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/CommandBuilder.kt
@@ -0,0 +1,41 @@
+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..8dffcdf
--- /dev/null
+++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/command/ServerChangelogsCommand.kt
@@ -0,0 +1,128 @@
+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
+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 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, _ ->
+ this@ServerChangelogsCommand.plugin.dialogHolder.getFromType()
+ .showTo(
+ player,
+ this@ServerChangelogsCommand.plugin.dialogSessionManager,
+ DialogPackets.PacketPhase.PLAY,
+ )
+ }
+ }
+
+ builder.literal("view") {
+ requiresPermission("command.view", true)
+ executesPlayer { player, _ ->
+ this@ServerChangelogsCommand.plugin.dialogHolder.getFromType()
+ .showTo(
+ player,
+ this@ServerChangelogsCommand.plugin.dialogSessionManager,
+ DialogPackets.PacketPhase.PLAY,
+ )
+ }
+ }
+
+ builder.literal("reload") {
+ requiresPermission("command.reload")
+ executesSourceSuspend { source, _ ->
+ this@ServerChangelogsCommand.executeReload(source, false)
+ }
+ literal("--force") {
+ executesSourceSuspend { source, _ ->
+ this@ServerChangelogsCommand.executeReload(source, true)
+ }
+ }
+ }
+
+ builder.literal("info") {
+ requiresPermission("command.info", true)
+ executesSource { source, _ ->
+ source.asAudience().sendMessage(TranslationSource.translatable("command.root"))
+ }
+ }
+ }
+
+ private suspend 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),
+ )
+ } catch (e: IllegalArgumentException) {
+ errorMsg = TranslationSource.translatable(
+ "command.reload.error.invalid",
+ Component.text(e.message ?: "Invalid configuration"),
+ )
+ } 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..f877e9d
--- /dev/null
+++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/config/PluginConfig.kt
@@ -0,0 +1,217 @@
+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 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.
+ */
+@Serializable
+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")
+ 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.
+ */
+class PluginConfig(
+ private val path: Path,
+) {
+ 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.
+ */
+ fun tryReload() {
+ runCatching {
+ this.reload()
+ }.onFailure { e ->
+ throw RuntimeException("Failed to reload configuration", e)
+ }
+ this.validate()?.let { err ->
+ throw RuntimeException("Configuration invalid: ${err}")
+ }
+ }
+
+ /**
+ * Asynchronously attempts to reload and validate configuration on Dispatchers.IO.
+ */
+ suspend fun tryReloadAsync() = withContext(Dispatchers.IO) {
+ this@PluginConfig.tryReload()
+ }
+
+ /**
+ * Asynchronously reloads configuration from disk on Dispatchers.IO.
+ */
+ suspend fun reloadAsync() = withContext(Dispatchers.IO) {
+ this@PluginConfig.reload()
+ }
+
+ /**
+ * Synchronously reads and deserializes the configuration file from disk.
+ */
+ fun reload() {
+ val text = this.path.readText()
+ this.data = if (text.isBlank()) {
+ PluginConfigData()
+ } else {
+ this.yaml.decodeFromString(PluginConfigData.serializer(), text)
+ }
+ }
+
+ /**
+ * 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()
+ this.dateFormatter
+ createChangelogHeaderStack()
+ this.enabledManualWorkarounds
+ null
+ }.getOrElse { it.message }
+
+ @Throws(RuntimeException::class)
+ 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))
+
+ fun registerDedicatedCommand(): Boolean {
+ return data.registerDedicatedCommand
+ }
+
+ val dialogPacketPhase: DialogPackets.PacketPhase
+ get() {
+ val rawPhase = data.dialogPhase
+ return runCatching {
+ DialogPackets.PacketPhase.valueOf(rawPhase.uppercase())
+ }.getOrElse {
+ throw IllegalArgumentException("Invalid dialog phase \"${rawPhase}\"")
+ }
+ }
+
+ val enabledManualWorkarounds: Set
+ get() {
+ return data.enableManualWorkarounds.map { workaround ->
+ runCatching {
+ PacketEventsFix.Workaround.valueOf(workaround.uppercase())
+ }.getOrElse {
+ throw IllegalArgumentException("Invalid manual workaround ID \"${workaround}\"")
+ }
+ }.toSet()
+ }
+
+ fun showChangelogHeader(): Boolean {
+ return data.dialogHeader
+ }
+
+ fun useNativeFallbackPermissions(): Boolean {
+ return data.useNativeFallbackPermissions
+ }
+
+ 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,
+ 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 {
+ Key.key(idPart)
+ }.getOrElse { e ->
+ throw RuntimeException("Invalid item ID \"${idPart}\"", e)
+ }
+ 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..546a4ce
--- /dev/null
+++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogEntry.kt
@@ -0,0 +1,32 @@
+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 hasRead(player.uniqueId)
+ }
+
+ /**
+ * Checks whether player with [uuid] has already marked this entry as read.
+ */
+ fun hasRead(uuid: UUID): Boolean {
+ return this.playersRead.contains(uuid)
+ }
+}
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..a936dc3
--- /dev/null
+++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/ChangelogStorage.kt
@@ -0,0 +1,206 @@
+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 org.slf4j.LoggerFactory
+import org.slf4j.kotlin.KLogger
+import java.nio.file.Path
+import java.time.Instant
+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 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) {
+ markAllAsRead(listOf(uid), player)
+ }
+
+ /**
+ * Retrieves the instant [player] was first observed connecting to the server.
+ */
+ fun getFirstSeenAt(player: UUID): Instant?
+
+ /**
+ * Records [player]'s first observed connection timestamp.
+ * If already recorded, returns the existing timestamp without modifying.
+ */
+ fun recordFirstSeen(player: UUID, seenAt: Instant = Instant.now()): Instant
+
+ /**
+ * Determines whether [entry] should be treated as unread for [player].
+ * An entry is only unread if the player has not yet read it and it was published
+ * after the player was first seen on the server.
+ */
+ fun isUnreadFor(entry: ChangelogEntry, player: UUID): Boolean {
+ val firstSeen = getFirstSeenAt(player) ?: return false
+ return !entry.hasRead(player) && entry.recordedAt.isAfter(firstSeen)
+ }
+
+ /**
+ * 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.
+ */
+ 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 [config].
+ *
+ * @throws IllegalArgumentException if the storage type identifier is unsupported.
+ */
+ @Throws(IllegalArgumentException::class)
+ fun create(
+ config: PluginConfig,
+ 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 { KLogger(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].
+ *
+ * @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/ExposedChangelogStorage.kt b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/ExposedChangelogStorage.kt
new file mode 100644
index 0000000..2df7356
--- /dev/null
+++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/ExposedChangelogStorage.kt
@@ -0,0 +1,369 @@
+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)
+}
+
+/**
+ * Exposed database table tracking player first-seen connection timestamps.
+ */
+object PlayerFirstSeenTable : Table("server_changelogs_first_seen") {
+ val playerUuid = varchar("player_uuid", 36)
+ val firstSeenAt = timestamp("first_seen_at")
+
+ override val primaryKey = PrimaryKey(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 val playerFirstSeen: MutableMap = java.util.concurrent.ConcurrentHashMap()
+ 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, PlayerFirstSeenTable)
+ }
+ if (config.autoMigrateYaml) {
+ checkAutoMigrate()
+ }
+ synchronized(lock) {
+ loadFromDatabaseLocked()
+ }
+ }
+
+ override fun shutdown() {
+ }
+
+ override fun getFirstSeenAt(player: UUID): Instant? = playerFirstSeen[player]
+
+ override fun recordFirstSeen(player: UUID, seenAt: Instant): Instant {
+ val existing = playerFirstSeen[player]
+ if (existing != null) return existing
+ synchronized(lock) {
+ val doubleCheck = playerFirstSeen[player]
+ if (doubleCheck != null) return doubleCheck
+ playerFirstSeen[player] = seenAt
+ transaction(this.database) {
+ PlayerFirstSeenTable.batchInsert(listOf(player to seenAt), ignore = true) { (p, t) ->
+ this[PlayerFirstSeenTable.playerUuid] = p.toString()
+ this[PlayerFirstSeenTable.firstSeenAt] = t
+ }
+ }
+ return seenAt
+ }
+ }
+
+ 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)
+ }
+ }
+
+ PlayerFirstSeenTable.selectAll().forEach { row ->
+ val pUuid = runCatching { UUID.fromString(row[PlayerFirstSeenTable.playerUuid]) }.getOrNull()
+ if (pUuid != null) {
+ playerFirstSeen[pUuid] = row[PlayerFirstSeenTable.firstSeenAt]
+ }
+ }
+
+ 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/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..65ab3b6
--- /dev/null
+++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/data/storage/YamlChangelogStorage.kt
@@ -0,0 +1,249 @@
+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 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 java.util.concurrent.ConcurrentHashMap
+import kotlin.io.path.exists
+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(),
+ val playerFirstSeen: Map = emptyMap(),
+)
+
+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()
+ private var playerFirstSeen: MutableMap = ConcurrentHashMap()
+
+ override val displayName: String = "YAML File"
+
+ override fun init() {
+ synchronized(lock) {
+ if (!filePath.exists()) {
+ this.cache = mutableListOf()
+ this.playerFirstSeen = ConcurrentHashMap()
+ this.saveLocked()
+ return
+ }
+ try {
+ val text = filePath.readText()
+ val doc = if (text.isBlank()) {
+ YamlChangelogDocument()
+ } else {
+ yaml.decodeFromString(YamlChangelogDocument.serializer(), text)
+ }
+ this.cache = doc.entries.mapIndexedTo(mutableListOf()) { index, entry ->
+ deserializeEntry(index, entry)
+ }
+ this.playerFirstSeen = doc.playerFirstSeen.mapNotNull { (uuidStr, timeStr) ->
+ runCatching {
+ UUID.fromString(uuidStr) to DateTimeFormatter.ISO_INSTANT.parse(timeStr, Instant::from)
+ }.getOrNull()
+ }.toMap(ConcurrentHashMap())
+ } catch (e: Exception) {
+ throw RuntimeException("Failed to load changelog data from ${filePath}", e)
+ }
+ }
+ }
+
+ override fun shutdown() {
+ }
+
+ override fun getFirstSeenAt(player: UUID): Instant? = playerFirstSeen[player]
+
+ override fun recordFirstSeen(player: UUID, seenAt: Instant): Instant {
+ synchronized(lock) {
+ val existing = playerFirstSeen[player]
+ if (existing != null) return existing
+ playerFirstSeen[player] = seenAt
+ saveLocked()
+ return seenAt
+ }
+ }
+
+ 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 storedEntries = this.cache.map { serializeEntry(it) }
+ val serializedFirstSeen = this.playerFirstSeen.mapKeys { it.key.toString() }
+ .mapValues { DateTimeFormatter.ISO_INSTANT.format(it.value) }
+ val doc = YamlChangelogDocument(
+ entries = storedEntries,
+ playerFirstSeen = serializedFirstSeen,
+ )
+ val text = yaml.encodeToString(YamlChangelogDocument.serializer(), doc)
+ val tempPath = filePath.resolveSibling("${filePath.fileName}.tmp")
+ try {
+ tempPath.writeText(text)
+ 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 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): 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 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()
+ }
+ }
+
+ override fun getByUID(uid: Int): ChangelogEntry? {
+ synchronized(lock) {
+ return this.cache.find { it.uid == uid }
+ }
+ }
+
+ override fun markAllAsRead(uids: Collection, player: UUID) {
+ synchronized(lock) {
+ 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/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..e44ef2d
--- /dev/null
+++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/DialogSessionManager.kt
@@ -0,0 +1,229 @@
+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 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.ConcurrentHashMap
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * 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 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 deferred = this.frozenViewers[player.uniqueId]
+ return deferred != null && !deferred.isCompleted
+ }
+
+ fun unfreeze(player: PlatformPlayer) {
+ val deferred = this.frozenViewers.remove(player.uniqueId)
+ deferred?.complete(Unit)
+ }
+
+ fun freeze(player: PlatformPlayer) {
+ this.frozenViewers.computeIfAbsent(player.uniqueId) { CompletableDeferred() }
+ }
+
+ suspend fun awaitUnfreeze(player: PlatformPlayer, timeoutSeconds: Long = 45) {
+ val deferred = this.frozenViewers[player.uniqueId] ?: return
+ runCatching {
+ withTimeout(timeoutSeconds * 1000) {
+ deferred.await()
+ }
+ }.onFailure {
+ // 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)
+ }
+
+ 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..b826598
--- /dev/null
+++ b/src/base/src/main/kotlin/com/codingcat/changelogs/base/dialog/ui/ChangelogDialog.kt
@@ -0,0 +1,270 @@
+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
+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 kotlinx.coroutines.launch
+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
+
+/**
+ * Dialog UI presenting published server changelogs to players.
+ */
+open 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"
+
+ /**
+ * Shows the changelog view dialog to the specified player during the given packet phase.
+ */
+ open fun showDialogView(
+ player: PlatformPlayer,
+ sessionManager: DialogSessionManager,
+ packetPhase: DialogPackets.PacketPhase,
+ ) {
+ showTo(player, sessionManager, packetPhase)
+ }
+
+ 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 = this@ChangelogDialog.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 (this.storage.isUnreadFor(entry, player.uniqueId)) "_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 {
+ this.storage.isUnreadFor(it, 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)
+ 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()
+ ServerChangelogs.platform.coroutineScope.launch {
+ this@ChangelogDialog.storage.removeEntryAsync(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