From c1f9ed6f938d228b11a8ce360ef1adea15175018 Mon Sep 17 00:00:00 2001 From: TiggerTakkyTTT Date: Mon, 28 Sep 2026 17:40:18 +0700 Subject: [PATCH] Add public plugin API --- .github/workflows/build.yml | 5 +- README.md | 8 +- api/README.md | 193 ++++++++++++++++++ api/build.gradle.kts | 56 +++++ .../codingcat/changelogs/api/Changelog.java | 31 +++ .../changelogs/api/ChangelogSubscription.java | 17 ++ .../changelogs/api/ServerChangelogsApi.java | 146 +++++++++++++ .../changelogs/api/event/ChangelogEvent.java | 7 + .../api/event/ChangelogPublishedEvent.java | 19 ++ .../api/event/ChangelogReadEvent.java | 22 ++ .../changelogs/api/ChangelogTest.java | 55 +++++ .../api/ServerChangelogsApiTest.java | 91 +++++++++ base/build.gradle.kts | 7 + .../changelogs/base/ServerChangelogs.java | 10 + .../base/api/ChangelogEventDispatcher.java | 53 +++++ .../changelogs/base/api/ChangelogService.java | 96 +++++++++ .../base/api/ServerChangelogsApiImpl.java | 126 ++++++++++++ .../changelogs/base/dialog/IDialog.java | 3 +- .../base/dialog/ui/ChangelogDialog.java | 4 +- .../ui/editor/ChangelogEditorDialog.java | 4 +- .../base/dialog/ui/editor/EditorSession.java | 20 +- .../api/ChangelogEventDispatcherTest.java | 58 ++++++ .../base/api/ChangelogServiceTest.java | 124 +++++++++++ gradle/libs.versions.toml | 4 + settings.gradle.kts | 2 +- 25 files changed, 1141 insertions(+), 20 deletions(-) create mode 100644 api/README.md create mode 100644 api/build.gradle.kts create mode 100644 api/src/main/java/com/codingcat/changelogs/api/Changelog.java create mode 100644 api/src/main/java/com/codingcat/changelogs/api/ChangelogSubscription.java create mode 100644 api/src/main/java/com/codingcat/changelogs/api/ServerChangelogsApi.java create mode 100644 api/src/main/java/com/codingcat/changelogs/api/event/ChangelogEvent.java create mode 100644 api/src/main/java/com/codingcat/changelogs/api/event/ChangelogPublishedEvent.java create mode 100644 api/src/main/java/com/codingcat/changelogs/api/event/ChangelogReadEvent.java create mode 100644 api/src/test/java/com/codingcat/changelogs/api/ChangelogTest.java create mode 100644 api/src/test/java/com/codingcat/changelogs/api/ServerChangelogsApiTest.java create mode 100644 base/src/main/java/com/codingcat/changelogs/base/api/ChangelogEventDispatcher.java create mode 100644 base/src/main/java/com/codingcat/changelogs/base/api/ChangelogService.java create mode 100644 base/src/main/java/com/codingcat/changelogs/base/api/ServerChangelogsApiImpl.java create mode 100644 base/src/test/java/com/codingcat/changelogs/base/api/ChangelogEventDispatcherTest.java create mode 100644 base/src/test/java/com/codingcat/changelogs/base/api/ChangelogServiceTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4ef0f5c..af9dc89 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,8 @@ jobs: - name: Upload built artifacts uses: actions/upload-artifact@v4 with: - name: Platform Artifacts + name: Build Artifacts path: |- + api/build/libs/*.jar paper/build/libs/*-all.jar - velocity/build/libs/*-all.jar \ No newline at end of file + velocity/build/libs/*-all.jar diff --git a/README.md b/README.md index d601c8c..7c964d6 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,12 @@ 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 `:`. +## Developer API + +ServerChangelogs provides a separately publishable, platform-neutral API for other plugins without exposing internal +storage or platform-specific plugin classes. See the [complete API reference](api/README.md) for dependency setup, +every method and return value, changelog data, events, subscriptions, lifecycle rules, and examples. + ## 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. @@ -121,4 +127,4 @@ The roadmap below contains some of the features that I'm **planning to add** in - [ ] **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 +> 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/api/README.md b/api/README.md new file mode 100644 index 0000000..5065487 --- /dev/null +++ b/api/README.md @@ -0,0 +1,193 @@ +# ServerChangelogs API + +The ServerChangelogs API lets Paper and Velocity plugins publish and inspect changelogs, track whether players have +read them, open the changelog menu, and observe publish/read events. It is platform-neutral and does not expose the +plugin's internal storage or dialog implementation. + +## Dependency and runtime setup + +The API is published separately from the platform plugin: + +```kotlin +dependencies { + compileOnly("com.codingcat:serverchangelogs-api:") +} +``` + +Use `compileOnly`; do not shade or relocate the API into your plugin. Your Paper or Velocity plugin must declare +ServerChangelogs as a required runtime dependency so it loads first and both plugins use the API classes provided by +ServerChangelogs. + +The API module is configured to publish sources and Javadocs alongside the main Maven artifact. This repository does +not configure a remote Maven repository; release infrastructure must provide the repository URL and credentials. + +## Getting the API + +```java +if (!ServerChangelogsApi.isAvailable()) { + throw new IllegalStateException("ServerChangelogs has not enabled"); +} + +ServerChangelogsApi api = ServerChangelogsApi.get(); +``` + +| Method | What it does | +| --- | --- | +| `ServerChangelogsApi.isAvailable()` | Returns `true` after ServerChangelogs has enabled and registered its provider. It returns `false` before enable and after disable. | +| `ServerChangelogsApi.get()` | Returns the active API instance. It throws `IllegalStateException` when the plugin is unavailable. | + +Keep the returned instance only while ServerChangelogs is enabled. A cached instance rejects calls with +`IllegalStateException` after ServerChangelogs disables. API calls are synchronous and the backing storage is not +thread-safe, so call all methods—including queries—from the server's main thread unless the active platform explicitly +guarantees another context. + +## Changelog data + +`Changelog` is an immutable snapshot with these accessors: + +| Accessor | Value | +| --- | --- | +| `id()` | The changelog's current non-negative storage identifier. | +| `lines()` | An immutable `List` containing the displayed changelog lines. | +| `publishedAt()` | The `Instant` at which the changelog was created. | +| `author()` | An `Optional` containing the displayed author. | + +The public `Changelog` constructor creates an immutable snapshot and rejects a negative ID, an empty line list, null +lines, a null timestamp, or a null `Optional`. Changelog IDs are current storage positions and may change when an +earlier entry is deleted, so integrations must not treat them as permanent external identifiers. + +## Creating changelogs + +```java +Changelog withoutAuthor = api.createChangelog( + List.of(Component.text("Added a new game mode")) +); + +Changelog withAuthor = api.createChangelog( + List.of( + Component.text("Added a new game mode"), + Component.text("Updated the lobby") + ), + Component.text("Server Team") +); +``` + +| Method | What it does | +| --- | --- | +| `createChangelog(List lines)` | Persists a changelog without an author and returns its immutable snapshot. The line list must not be empty or contain null. | +| `createChangelog(List lines, Component author)` | Persists a changelog with an author and returns its immutable snapshot. Neither argument may be null. | + +Both overloads fire one `ChangelogPublishedEvent` synchronously after storage succeeds. + +## Reading changelogs + +| Method | What it does | +| --- | --- | +| `getChangelogs()` | Returns an immutable list of immutable snapshots in publication order, oldest first. An empty list means no changelogs exist. | +| `getChangelog(int id)` | Returns the changelog with the current ID, or `Optional.empty()` when it does not exist. | +| `getLatestChangelog()` | Returns the most recently published changelog, or `Optional.empty()` when storage is empty. | + +```java +api.getLatestChangelog().ifPresent(changelog -> + logger.info("Latest changelog ID: {}", changelog.id()) +); +``` + +## Player read state + +Read state uses player UUIDs, so it also works when a player is offline. + +| Method | What it does | +| --- | --- | +| `hasRead(int changelogId, UUID playerId)` | Returns `true` if that player has read the changelog. It returns `false` for an unknown changelog or an unread player. | +| `markAsRead(int changelogId, UUID playerId)` | Changes an existing changelog from unread to read for that player. It returns `true` only for the first successful transition and `false` if the changelog is missing or was already read. | + +A successful transition fires one `ChangelogReadEvent`. Repeating `markAsRead` does not fire another event. + +## Opening the menu + +```java +boolean opened = api.openChangelogMenu(playerUuid); +``` + +`openChangelogMenu(UUID playerId)` opens the normal ServerChangelogs dialog for an online player and returns `true`. +It returns `false` when that UUID is not currently online. This method must be called from a context in which the +platform allows dialogs to be opened, normally the server's main thread. + +## Events and subscriptions + +Subscribe by exact event class: + +```java +ChangelogSubscription publishedSubscription = api.subscribe( + ChangelogPublishedEvent.class, + event -> logger.info("Published changelog {}", event.changelog().id()) +); + +ChangelogSubscription readSubscription = api.subscribe( + ChangelogReadEvent.class, + event -> logger.info( + "Player {} read changelog {}", + event.playerId(), + event.changelog().id() + ) +); +``` + +| API | What it does | +| --- | --- | +| `subscribe(Class eventType, Consumer listener)` | Registers a listener for that exact event type and returns its subscription handle. The event type and listener must not be null. | +| `ChangelogSubscription.unsubscribe()` | Removes the listener. Calling it repeatedly has no additional effect. | +| `ChangelogSubscription.close()` | Equivalent to `unsubscribe()`, allowing subscriptions to be managed as `AutoCloseable`. | +| `ChangelogPublishedEvent.changelog()` | Returns the immutable snapshot that was successfully published. | +| `ChangelogReadEvent.changelog()` | Returns the immutable snapshot that changed to read. | +| `ChangelogReadEvent.playerId()` | Returns the UUID of the player whose state changed. | + +Listeners execute synchronously on the thread that caused the event. A listener exception is logged and isolated so it +does not prevent the remaining listeners from running. Keep every subscription and close it when your plugin disables: + +```java +@Override +public void onDisable() { + publishedSubscription.close(); + readSubscription.close(); +} +``` + +ServerChangelogs also clears all remaining subscriptions when it shuts down. + +## Provider-only members + +`ServerChangelogsApi` has a protected constructor plus protected `registerProvider()` and `unregisterProvider()` +methods. These exist only for the implementation in the `base` module. Consumer plugins must not subclass +`ServerChangelogsApi` or attempt to register a provider. + +## Complete example + +```java +public final class ExampleIntegration { + private final ServerChangelogsApi api = ServerChangelogsApi.get(); + private final ChangelogSubscription readSubscription; + + public ExampleIntegration() { + this.readSubscription = api.subscribe(ChangelogReadEvent.class, event -> + System.out.println(event.playerId() + " read #" + event.changelog().id()) + ); + } + + public void publishAndShow(UUID playerId) { + Changelog changelog = api.createChangelog( + List.of(Component.text("Maintenance completed")), + Component.text("Operations") + ); + + if (!api.hasRead(changelog.id(), playerId)) { + api.openChangelogMenu(playerId); + } + } + + public void close() { + this.readSubscription.close(); + } +} +``` diff --git a/api/build.gradle.kts b/api/build.gradle.kts new file mode 100644 index 0000000..38a99ae --- /dev/null +++ b/api/build.gradle.kts @@ -0,0 +1,56 @@ +plugins { + id("java-library") + id("maven-publish") +} + +base { + archivesName = "serverchangelogs-api" +} + +dependencies { + api(libs.adventure.api) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) +} + +java { + toolchain.languageVersion = JavaLanguageVersion.of(25) + withSourcesJar() + withJavadocJar() +} + +tasks.test { + useJUnitPlatform() +} + +publishing { + publications { + create("mavenJava") { + from(components["java"]) + artifactId = "serverchangelogs-api" + + pom { + name = "ServerChangelogs API" + description = project.property("description") as String + url = project.property("url") as String + licenses { + license { + name = "MIT License" + url = "https://opensource.org/license/mit" + } + } + developers { + developer { + id = "codingcat2468" + name = "codingcat2468" + } + } + scm { + connection = "scm:git:git://github.com/codingcat2468/ServerChangelogs.git" + developerConnection = "scm:git:ssh://github.com/codingcat2468/ServerChangelogs.git" + url = "https://github.com/codingcat2468/ServerChangelogs" + } + } + } + } +} diff --git a/api/src/main/java/com/codingcat/changelogs/api/Changelog.java b/api/src/main/java/com/codingcat/changelogs/api/Changelog.java new file mode 100644 index 0000000..fb5d443 --- /dev/null +++ b/api/src/main/java/com/codingcat/changelogs/api/Changelog.java @@ -0,0 +1,31 @@ +package com.codingcat.changelogs.api; + +import net.kyori.adventure.text.Component; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * An immutable snapshot of a published changelog. + * + * @param id the changelog's current numeric identifier + * @param lines the lines displayed for this changelog + * @param publishedAt the time at which the changelog was published + * @param author the displayed author, if one was supplied + */ +public record Changelog(int id, List lines, Instant publishedAt, Optional author) { + /** + * Creates an immutable changelog snapshot. + */ + public Changelog { + if (id < 0) throw new IllegalArgumentException("id must not be negative"); + lines = List.copyOf(lines); + if (lines.isEmpty()) throw new IllegalArgumentException("lines must not be empty"); + if (lines.stream().anyMatch(Objects::isNull)) + throw new IllegalArgumentException("lines must not contain null values"); + Objects.requireNonNull(publishedAt, "publishedAt"); + Objects.requireNonNull(author, "author"); + } +} diff --git a/api/src/main/java/com/codingcat/changelogs/api/ChangelogSubscription.java b/api/src/main/java/com/codingcat/changelogs/api/ChangelogSubscription.java new file mode 100644 index 0000000..ecaa521 --- /dev/null +++ b/api/src/main/java/com/codingcat/changelogs/api/ChangelogSubscription.java @@ -0,0 +1,17 @@ +package com.codingcat.changelogs.api; + +/** + * A live subscription to ServerChangelogs events. + */ +@FunctionalInterface +public interface ChangelogSubscription extends AutoCloseable { + /** + * Stops this subscription. Calling this method more than once has no effect. + */ + void unsubscribe(); + + @Override + default void close() { + this.unsubscribe(); + } +} diff --git a/api/src/main/java/com/codingcat/changelogs/api/ServerChangelogsApi.java b/api/src/main/java/com/codingcat/changelogs/api/ServerChangelogsApi.java new file mode 100644 index 0000000..6e89f75 --- /dev/null +++ b/api/src/main/java/com/codingcat/changelogs/api/ServerChangelogsApi.java @@ -0,0 +1,146 @@ +package com.codingcat.changelogs.api; + +import com.codingcat.changelogs.api.event.ChangelogEvent; +import net.kyori.adventure.text.Component; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +/** + * The public, platform-neutral ServerChangelogs API. + * + *

API calls are synchronous and the backing storage is not thread-safe. Call + * all methods from the server's main thread unless the active platform explicitly + * documents otherwise.

+ */ +public abstract class ServerChangelogsApi { + private static final AtomicReference INSTANCE = new AtomicReference<>(); + + /** + * Constructor for the ServerChangelogs-managed implementation. + */ + protected ServerChangelogsApi() { + } + + /** + * Gets the API exposed by the running ServerChangelogs plugin. + * + * @return the active API instance + * @throws IllegalStateException if ServerChangelogs has not finished enabling + */ + public static ServerChangelogsApi get() { + ServerChangelogsApi api = INSTANCE.get(); + if (api == null) throw new IllegalStateException("ServerChangelogs is not enabled"); + return api; + } + + /** + * Checks whether a ServerChangelogs API instance is currently available. + * + * @return {@code true} after ServerChangelogs has enabled + */ + public static boolean isAvailable() { + return INSTANCE.get() != null; + } + + /** + * Registers this implementation as the active API provider. + * Intended only for the ServerChangelogs implementation. + */ + protected final void registerProvider() { + if (!INSTANCE.compareAndSet(null, this)) + throw new IllegalStateException("A ServerChangelogs API provider is already registered"); + } + + /** + * Removes this implementation if it is the active API provider. + * Intended only for the ServerChangelogs implementation. + */ + protected final void unregisterProvider() { + INSTANCE.compareAndSet(this, null); + } + + /** + * Publishes a changelog without a displayed author. + * + * @param lines the non-empty changelog contents + * @return an immutable snapshot of the published changelog + */ + public abstract Changelog createChangelog(List lines); + + /** + * Publishes a changelog. + * + * @param lines the non-empty changelog contents + * @param author the displayed author + * @return an immutable snapshot of the published changelog + */ + public abstract Changelog createChangelog(List lines, Component author); + + /** + * Gets all changelogs in publication order. + * + * @return immutable changelog snapshots + */ + public abstract List getChangelogs(); + + /** + * Looks up a changelog by its current identifier. + * + * @param id the current changelog identifier + * @return the changelog, or an empty optional if it does not exist + */ + public abstract Optional getChangelog(int id); + + /** + * Gets the most recently published changelog, if one exists. + * + * @return the latest changelog, or an empty optional when there are none + */ + public abstract Optional getLatestChangelog(); + + /** + * Checks whether a player has read a changelog. + * Returns {@code false} when the changelog does not exist. + * + * @param changelogId the current changelog identifier + * @param playerId the player's UUID + * @return whether the changelog exists and has been read by the player + */ + public abstract boolean hasRead(int changelogId, UUID playerId); + + /** + * Marks a changelog as read by a player. + * + * @param changelogId the current changelog identifier + * @param playerId the player's UUID + * @return {@code true} only when the changelog existed and changed from unread to read + */ + public abstract boolean markAsRead(int changelogId, UUID playerId); + + /** + * Opens the changelog menu for an online player. + * + * @param playerId the player's UUID + * @return {@code false} if the player is not currently online + */ + public abstract boolean openChangelogMenu(UUID playerId); + + /** + * Subscribes to one exact type of changelog event. Listeners run synchronously + * on the thread which caused the event. + * + * @param the event type + * @param eventType the event class to receive + * @param listener the listener to invoke + * @return a handle which can be closed to unsubscribe + */ + public abstract ChangelogSubscription subscribe( + Class eventType, + Consumer listener + ); + +} diff --git a/api/src/main/java/com/codingcat/changelogs/api/event/ChangelogEvent.java b/api/src/main/java/com/codingcat/changelogs/api/event/ChangelogEvent.java new file mode 100644 index 0000000..4ce6b14 --- /dev/null +++ b/api/src/main/java/com/codingcat/changelogs/api/event/ChangelogEvent.java @@ -0,0 +1,7 @@ +package com.codingcat.changelogs.api.event; + +/** + * A change emitted by the ServerChangelogs API. + */ +public sealed interface ChangelogEvent permits ChangelogPublishedEvent, ChangelogReadEvent { +} diff --git a/api/src/main/java/com/codingcat/changelogs/api/event/ChangelogPublishedEvent.java b/api/src/main/java/com/codingcat/changelogs/api/event/ChangelogPublishedEvent.java new file mode 100644 index 0000000..55ee888 --- /dev/null +++ b/api/src/main/java/com/codingcat/changelogs/api/event/ChangelogPublishedEvent.java @@ -0,0 +1,19 @@ +package com.codingcat.changelogs.api.event; + +import com.codingcat.changelogs.api.Changelog; + +import java.util.Objects; + +/** + * Fired synchronously after a changelog has been persisted. + * + * @param changelog the published changelog + */ +public record ChangelogPublishedEvent(Changelog changelog) implements ChangelogEvent { + /** + * Creates a published event. + */ + public ChangelogPublishedEvent { + Objects.requireNonNull(changelog, "changelog"); + } +} diff --git a/api/src/main/java/com/codingcat/changelogs/api/event/ChangelogReadEvent.java b/api/src/main/java/com/codingcat/changelogs/api/event/ChangelogReadEvent.java new file mode 100644 index 0000000..5f24bcc --- /dev/null +++ b/api/src/main/java/com/codingcat/changelogs/api/event/ChangelogReadEvent.java @@ -0,0 +1,22 @@ +package com.codingcat.changelogs.api.event; + +import com.codingcat.changelogs.api.Changelog; + +import java.util.Objects; +import java.util.UUID; + +/** + * Fired synchronously when a changelog changes from unread to read for a player. + * + * @param changelog the changelog which was read + * @param playerId the reader's UUID + */ +public record ChangelogReadEvent(Changelog changelog, UUID playerId) implements ChangelogEvent { + /** + * Creates a read event. + */ + public ChangelogReadEvent { + Objects.requireNonNull(changelog, "changelog"); + Objects.requireNonNull(playerId, "playerId"); + } +} diff --git a/api/src/test/java/com/codingcat/changelogs/api/ChangelogTest.java b/api/src/test/java/com/codingcat/changelogs/api/ChangelogTest.java new file mode 100644 index 0000000..0179c48 --- /dev/null +++ b/api/src/test/java/com/codingcat/changelogs/api/ChangelogTest.java @@ -0,0 +1,55 @@ +package com.codingcat.changelogs.api; + +import com.codingcat.changelogs.api.event.ChangelogPublishedEvent; +import com.codingcat.changelogs.api.event.ChangelogReadEvent; +import net.kyori.adventure.text.Component; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ChangelogTest { + @Test + void defensivelyCopiesLines() { + List lines = new ArrayList<>(List.of(Component.text("Original"))); + Changelog changelog = new Changelog(0, lines, Instant.EPOCH, Optional.empty()); + + lines.set(0, Component.text("Changed")); + + assertEquals(List.of(Component.text("Original")), changelog.lines()); + assertThrows(UnsupportedOperationException.class, () -> changelog.lines().add(Component.empty())); + } + + @Test + void rejectsInvalidSnapshots() { + List lines = List.of(Component.text("Changes")); + + assertThrows(IllegalArgumentException.class, + () -> new Changelog(-1, lines, Instant.EPOCH, Optional.empty())); + assertThrows(IllegalArgumentException.class, + () -> new Changelog(0, List.of(), Instant.EPOCH, Optional.empty())); + assertThrows(NullPointerException.class, + () -> new Changelog(0, null, Instant.EPOCH, Optional.empty())); + assertThrows(NullPointerException.class, + () -> new Changelog(0, java.util.Arrays.asList(Component.empty(), null), Instant.EPOCH, Optional.empty())); + assertThrows(NullPointerException.class, + () -> new Changelog(0, lines, null, Optional.empty())); + assertThrows(NullPointerException.class, + () -> new Changelog(0, lines, Instant.EPOCH, null)); + } + + @Test + void rejectsInvalidEvents() { + Changelog changelog = new Changelog(0, List.of(Component.text("Changes")), Instant.EPOCH, Optional.empty()); + + assertThrows(NullPointerException.class, () -> new ChangelogPublishedEvent(null)); + assertThrows(NullPointerException.class, () -> new ChangelogReadEvent(null, UUID.randomUUID())); + assertThrows(NullPointerException.class, () -> new ChangelogReadEvent(changelog, null)); + } +} diff --git a/api/src/test/java/com/codingcat/changelogs/api/ServerChangelogsApiTest.java b/api/src/test/java/com/codingcat/changelogs/api/ServerChangelogsApiTest.java new file mode 100644 index 0000000..d6fa3a0 --- /dev/null +++ b/api/src/test/java/com/codingcat/changelogs/api/ServerChangelogsApiTest.java @@ -0,0 +1,91 @@ +package com.codingcat.changelogs.api; + +import com.codingcat.changelogs.api.event.ChangelogEvent; +import net.kyori.adventure.text.Component; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ServerChangelogsApiTest { + @Test + void registersAndUnregistersProvider() { + TestApi api = new TestApi(); + + assertFalse(ServerChangelogsApi.isAvailable()); + assertThrows(IllegalStateException.class, ServerChangelogsApi::get); + + try { + api.enable(); + assertTrue(ServerChangelogsApi.isAvailable()); + assertSame(api, ServerChangelogsApi.get()); + assertThrows(IllegalStateException.class, new TestApi()::enable); + } finally { + api.disable(); + } + + assertFalse(ServerChangelogsApi.isAvailable()); + } + + private static final class TestApi extends ServerChangelogsApi { + void enable() { + this.registerProvider(); + } + + void disable() { + this.unregisterProvider(); + } + + @Override + public Changelog createChangelog(List lines) { + throw new UnsupportedOperationException(); + } + + @Override + public Changelog createChangelog(List lines, Component author) { + throw new UnsupportedOperationException(); + } + + @Override + public List getChangelogs() { + return List.of(); + } + + @Override + public Optional getChangelog(int id) { + return Optional.empty(); + } + + @Override + public Optional getLatestChangelog() { + return Optional.empty(); + } + + @Override + public boolean hasRead(int changelogId, UUID playerId) { + return false; + } + + @Override + public boolean markAsRead(int changelogId, UUID playerId) { + return false; + } + + @Override + public boolean openChangelogMenu(UUID playerId) { + return false; + } + + @Override + public ChangelogSubscription subscribe(Class eventType, Consumer listener) { + return () -> { }; + } + } +} diff --git a/base/build.gradle.kts b/base/build.gradle.kts index b1c4203..0dfd88d 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -4,6 +4,7 @@ plugins { } dependencies { + implementation(project(":api")) 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 @@ -15,8 +16,14 @@ dependencies { compileOnly(libs.adventure.minimessage) compileOnly(libs.adventure.gson) compileOnly(libs.packetevents.api) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) } java { toolchain.languageVersion = JavaLanguageVersion.of(25) } + +tasks.test { + useJUnitPlatform() +} diff --git a/base/src/main/java/com/codingcat/changelogs/base/ServerChangelogs.java b/base/src/main/java/com/codingcat/changelogs/base/ServerChangelogs.java index d012551..413d411 100644 --- a/base/src/main/java/com/codingcat/changelogs/base/ServerChangelogs.java +++ b/base/src/main/java/com/codingcat/changelogs/base/ServerChangelogs.java @@ -1,5 +1,7 @@ package com.codingcat.changelogs.base; +import com.codingcat.changelogs.api.ServerChangelogsApi; +import com.codingcat.changelogs.base.api.ServerChangelogsApiImpl; import com.codingcat.changelogs.base.command.BrigadierCommandNode; import com.codingcat.changelogs.base.command.DialogSubCommands; import com.codingcat.changelogs.base.compat.PacketEventsFix; @@ -49,6 +51,7 @@ public final class ServerChangelogs extends Entrypoint { private @Getter ChangelogStorage changelogStorage; private @Getter IDialog.Holder dialogHolder; private @Getter DialogSessionManager dialogSessionManager; + private ServerChangelogsApiImpl api; public ServerChangelogs(@NotNull ChangelogsPlatform platform) { super(platform); @@ -86,6 +89,7 @@ public void onStart() { this.changelogStorage = this.config.createChangelogStorage(); info("console.startup_storage", text(this.changelogStorage.getDisplayName())); this.changelogStorage.init(); + this.api = new ServerChangelogsApiImpl(this); PacketEventsFix.setManualWorkarounds(config.getEnabledManualWorkarounds(), logger); this.dialogHolder = new IDialog.Holder(this); this.dialogHolder.recreate(); @@ -94,6 +98,7 @@ public void onStart() { this.joinListener = new ChangelogJoinListener(this::getChangelogStorage, dialogHolder, dialogSessionManager, config); this.joinListener.registerEvents(getPlatform().getEventManager()); this.registerCommands(getPlatform().getCommandManager()); + this.api.enable(); } private void registerCommands(@NotNull ICommandManager commandManager) { @@ -125,6 +130,7 @@ public void reload(boolean force) throws IOException, YAMLException, IDialog.Des @Override public void onShutdown() { info("console.shutdown"); + if (this.api != null) this.api.disable(); if (this.dialogSessionManager != null) this.dialogSessionManager.unregisterEvents(); if (this.joinListener != null) this.joinListener.unregisterEvents(getPlatform().getEventManager()); if (this.changelogStorage != null) this.changelogStorage.shutdown(); @@ -134,6 +140,10 @@ public void onShutdown() { return this.config; } + public @NotNull ServerChangelogsApi getApi() { + return this.api; + } + public static void info(@NotNull String key, @NotNull ComponentLike... args) { logger.info(translateConsole(key, args)); } diff --git a/base/src/main/java/com/codingcat/changelogs/base/api/ChangelogEventDispatcher.java b/base/src/main/java/com/codingcat/changelogs/base/api/ChangelogEventDispatcher.java new file mode 100644 index 0000000..f118be8 --- /dev/null +++ b/base/src/main/java/com/codingcat/changelogs/base/api/ChangelogEventDispatcher.java @@ -0,0 +1,53 @@ +package com.codingcat.changelogs.base.api; + +import com.codingcat.changelogs.api.ChangelogSubscription; +import com.codingcat.changelogs.api.event.ChangelogEvent; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +final class ChangelogEventDispatcher { + private final @NotNull Map, CopyOnWriteArrayList>> listeners = new ConcurrentHashMap<>(); + private final @NotNull BiConsumer exceptionHandler; + + ChangelogEventDispatcher(@NotNull BiConsumer exceptionHandler) { + this.exceptionHandler = exceptionHandler; + } + + @NotNull ChangelogSubscription subscribe( + @NotNull Class eventType, + @NotNull Consumer listener + ) { + Consumer adapter = event -> listener.accept(eventType.cast(event)); + CopyOnWriteArrayList> eventListeners = + this.listeners.computeIfAbsent(eventType, _ -> new CopyOnWriteArrayList<>()); + eventListeners.add(adapter); + AtomicBoolean active = new AtomicBoolean(true); + return () -> { + if (!active.compareAndSet(true, false)) return; + eventListeners.remove(adapter); + if (eventListeners.isEmpty()) this.listeners.remove(eventType, eventListeners); + }; + } + + void dispatch(@NotNull ChangelogEvent event) { + CopyOnWriteArrayList> eventListeners = this.listeners.get(event.getClass()); + if (eventListeners == null) return; + for (Consumer listener : eventListeners) { + try { + listener.accept(event); + } catch (RuntimeException e) { + this.exceptionHandler.accept(event, e); + } + } + } + + void clear() { + this.listeners.clear(); + } +} diff --git a/base/src/main/java/com/codingcat/changelogs/base/api/ChangelogService.java b/base/src/main/java/com/codingcat/changelogs/base/api/ChangelogService.java new file mode 100644 index 0000000..8f24e6c --- /dev/null +++ b/base/src/main/java/com/codingcat/changelogs/base/api/ChangelogService.java @@ -0,0 +1,96 @@ +package com.codingcat.changelogs.base.api; + +import com.codingcat.changelogs.api.Changelog; +import com.codingcat.changelogs.api.event.ChangelogEvent; +import com.codingcat.changelogs.api.event.ChangelogPublishedEvent; +import com.codingcat.changelogs.api.event.ChangelogReadEvent; +import com.codingcat.changelogs.base.data.ChangelogEntry; +import com.codingcat.changelogs.base.data.ChangelogStorage; +import net.kyori.adventure.text.Component; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Supplier; + +final class ChangelogService { + private final @NotNull Supplier storageSupplier; + private final @NotNull Consumer eventSink; + + ChangelogService( + @NotNull Supplier storageSupplier, + @NotNull Consumer eventSink + ) { + this.storageSupplier = storageSupplier; + this.eventSink = eventSink; + } + + @NotNull Changelog create(@NotNull List lines, @Nullable Component author) { + ChangelogStorage storage = this.storage(); + Changelog changelog = new Changelog( + storage.nextUID(), + lines, + Instant.now(), + Optional.ofNullable(author) + ); + storage.storeEntry(new ChangelogEntry( + changelog.id(), + changelog.lines(), + changelog.publishedAt(), + changelog.author().orElse(null), + new HashSet<>() + )); + this.eventSink.accept(new ChangelogPublishedEvent(changelog)); + return changelog; + } + + @NotNull List getAll() { + return this.storage().listEntries().stream() + .map(ChangelogService::snapshot) + .toList(); + } + + @NotNull Optional get(int id) { + return Optional.ofNullable(this.storage().getByUID(id)) + .map(ChangelogService::snapshot); + } + + @NotNull Optional getLatest() { + List entries = this.storage().listEntries(); + if (entries.isEmpty()) return Optional.empty(); + return Optional.of(snapshot(entries.getLast())); + } + + boolean hasRead(int id, @NotNull UUID playerId) { + ChangelogEntry entry = this.storage().getByUID(id); + return entry != null && entry.playersRead().contains(playerId); + } + + boolean markAsRead(int id, @NotNull UUID playerId) { + ChangelogStorage storage = this.storage(); + ChangelogEntry entry = storage.getByUID(id); + if (entry == null || entry.playersRead().contains(playerId)) return false; + storage.markAsRead(id, playerId); + this.eventSink.accept(new ChangelogReadEvent(snapshot(entry), playerId)); + return true; + } + + private @NotNull ChangelogStorage storage() { + return Objects.requireNonNull(this.storageSupplier.get(), "Changelog storage is not initialized"); + } + + private static @NotNull Changelog snapshot(@NotNull ChangelogEntry entry) { + return new Changelog( + entry.uid(), + entry.lines(), + entry.recordedAt(), + Optional.ofNullable(entry.author()) + ); + } +} diff --git a/base/src/main/java/com/codingcat/changelogs/base/api/ServerChangelogsApiImpl.java b/base/src/main/java/com/codingcat/changelogs/base/api/ServerChangelogsApiImpl.java new file mode 100644 index 0000000..5b089ea --- /dev/null +++ b/base/src/main/java/com/codingcat/changelogs/base/api/ServerChangelogsApiImpl.java @@ -0,0 +1,126 @@ +package com.codingcat.changelogs.base.api; + +import com.codingcat.changelogs.api.Changelog; +import com.codingcat.changelogs.api.ChangelogSubscription; +import com.codingcat.changelogs.api.ServerChangelogsApi; +import com.codingcat.changelogs.api.event.ChangelogEvent; +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.platformapi.player.IPlayer; +import net.kyori.adventure.text.Component; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +public final class ServerChangelogsApiImpl extends ServerChangelogsApi { + private final @NotNull ServerChangelogs plugin; + private final @NotNull ChangelogEventDispatcher eventDispatcher; + private final @NotNull ChangelogService changelogService; + private final @NotNull AtomicBoolean enabled = new AtomicBoolean(false); + + public ServerChangelogsApiImpl(@NotNull ServerChangelogs plugin) { + this.plugin = plugin; + this.eventDispatcher = new ChangelogEventDispatcher((event, error) -> + plugin.getPlatform().getComponentLogger().error( + "A ServerChangelogs API listener failed while handling {}", + event.getClass().getSimpleName(), + error + )); + this.changelogService = new ChangelogService(plugin::getChangelogStorage, this.eventDispatcher::dispatch); + } + + public void enable() { + if (!this.enabled.compareAndSet(false, true)) + throw new IllegalStateException("The ServerChangelogs API is already enabled"); + try { + this.registerProvider(); + } catch (RuntimeException e) { + this.enabled.set(false); + throw e; + } + } + + public void disable() { + if (!this.enabled.compareAndSet(true, false)) return; + this.unregisterProvider(); + this.eventDispatcher.clear(); + } + + @Override + public @NotNull Changelog createChangelog(@NotNull List lines) { + this.ensureEnabled(); + return this.changelogService.create(Objects.requireNonNull(lines, "lines"), null); + } + + @Override + public @NotNull Changelog createChangelog(@NotNull List lines, @NotNull Component author) { + this.ensureEnabled(); + return this.changelogService.create( + Objects.requireNonNull(lines, "lines"), + Objects.requireNonNull(author, "author") + ); + } + + @Override + public @NotNull List getChangelogs() { + this.ensureEnabled(); + return this.changelogService.getAll(); + } + + @Override + public @NotNull Optional getChangelog(int id) { + this.ensureEnabled(); + return this.changelogService.get(id); + } + + @Override + public @NotNull Optional getLatestChangelog() { + this.ensureEnabled(); + return this.changelogService.getLatest(); + } + + @Override + public boolean hasRead(int changelogId, @NotNull UUID playerId) { + this.ensureEnabled(); + return this.changelogService.hasRead(changelogId, Objects.requireNonNull(playerId, "playerId")); + } + + @Override + public boolean markAsRead(int changelogId, @NotNull UUID playerId) { + this.ensureEnabled(); + return this.changelogService.markAsRead(changelogId, Objects.requireNonNull(playerId, "playerId")); + } + + @Override + public boolean openChangelogMenu(@NotNull UUID playerId) { + this.ensureEnabled(); + IPlayer player = this.plugin.getPlatform().getPlayerManager() + .getFromUUID(Objects.requireNonNull(playerId, "playerId")); + if (player == null) return false; + this.plugin.getDialogHolder().getFromType(ChangelogDialog.class) + .showTo(player, this.plugin.getDialogSessionManager(), DialogPackets.PacketPhase.PLAY); + return true; + } + + @Override + public @NotNull ChangelogSubscription subscribe( + @NotNull Class eventType, + @NotNull Consumer listener + ) { + this.ensureEnabled(); + return this.eventDispatcher.subscribe( + Objects.requireNonNull(eventType, "eventType"), + Objects.requireNonNull(listener, "listener") + ); + } + + private void ensureEnabled() { + if (!this.enabled.get()) throw new IllegalStateException("ServerChangelogs is not enabled"); + } +} 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 index bcb3ebe..7023b0d 100644 --- a/base/src/main/java/com/codingcat/changelogs/base/dialog/IDialog.java +++ b/base/src/main/java/com/codingcat/changelogs/base/dialog/IDialog.java @@ -44,10 +44,11 @@ public void recreate() { this.dialogMap.clear(); register.accept(new ChangelogEditorDialog( plugin.getChangelogStorage(), + plugin.getApi(), plugin.pluginConfig().useNativeFallbackPermissions() )); register.accept(new ChangelogDialog( - plugin.getChangelogStorage(), this, + plugin.getChangelogStorage(), plugin.getApi(), this, plugin.pluginConfig().getDateFormatter(), plugin.pluginConfig().showChangelogHeader(), plugin.pluginConfig().createChangelogHeaderStack(), 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 index 288428a..c403c2e 100644 --- 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 @@ -1,5 +1,6 @@ package com.codingcat.changelogs.base.dialog.ui; +import com.codingcat.changelogs.api.ServerChangelogsApi; import com.codingcat.changelogs.base.compat.PacketEventsFix; import com.codingcat.changelogs.base.data.ChangelogEntry; import com.codingcat.changelogs.base.data.ChangelogStorage; @@ -44,6 +45,7 @@ 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 ServerChangelogsApi api; private final @NotNull IDialog.Holder holder; private final @NotNull DateTimeFormatter dateFormatter; private final boolean addHeader; @@ -126,7 +128,7 @@ public void onActionTriggered(@NotNull String action, @Nullable NBTCompound data .filter(e -> this.storage.isUnreadFor(e, source.getUniqueId())) .map(ChangelogEntry::uid) .toList(); - uids.forEach(uid -> storage.markAsRead(uid, source.getUniqueId())); + uids.forEach(uid -> api.markAsRead(uid, source.getUniqueId())); if (canManage) { sessionManager.endSession(source); DialogPackets.clearDialog(source, DialogPackets.PacketPhase.PLAY); 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 index c60eb97..2d97051 100644 --- 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 @@ -1,5 +1,6 @@ package com.codingcat.changelogs.base.dialog.ui.editor; +import com.codingcat.changelogs.api.ServerChangelogsApi; import com.codingcat.changelogs.base.ServerChangelogs; import com.codingcat.changelogs.base.compat.PacketEventsFix; import com.codingcat.changelogs.base.data.ChangelogStorage; @@ -52,6 +53,7 @@ public class ChangelogEditorDialog implements IDialog { private final @NotNull Map savedSessions = new ConcurrentHashMap<>(); private final @Getter String id = "changelog_editor"; private final @NotNull ChangelogStorage storage; + private final @NotNull ServerChangelogsApi api; private final boolean useFallbackPermissions; @Override @@ -206,7 +208,7 @@ public void onActionTriggered(@NotNull String action, @Nullable NBTCompound data return; } try { - session.commit(this.storage); + session.commit(this.storage, this.api); } catch (EditorSession.CommitException e) { this.showRetry(source, session.getId() + "." + e.getTranslationKeyPart(), session, sessionManager); return; 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 index 979118e..5c3b9fa 100644 --- 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 @@ -1,5 +1,6 @@ package com.codingcat.changelogs.base.dialog.ui.editor; +import com.codingcat.changelogs.api.ServerChangelogsApi; import com.codingcat.changelogs.base.data.ChangelogEntry; import com.codingcat.changelogs.base.data.ChangelogStorage; import lombok.Getter; @@ -11,9 +12,7 @@ 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 @@ -27,7 +26,7 @@ public abstract class EditorSession { private @NotNull String author = ""; private boolean showRestoredMessage = false; - abstract void commit(@NotNull ChangelogStorage storage) throws CommitException; + abstract void commit(@NotNull ChangelogStorage storage, @NotNull ServerChangelogsApi api) throws CommitException; public abstract @NotNull String getId(); @@ -51,16 +50,11 @@ public Create(int entryUID) { } @Override - public void commit(@NotNull ChangelogStorage storage) throws CommitException { - ChangelogEntry newEntry = new ChangelogEntry( - getEntryUID(), - this.deserializeLines(), - Instant.now(), - this.deserializeAuthor(), - new HashSet<>() - ); + public void commit(@NotNull ChangelogStorage storage, @NotNull ServerChangelogsApi api) throws CommitException { try { - storage.storeEntry(newEntry); + Component author = this.deserializeAuthor(); + if (author == null) api.createChangelog(this.deserializeLines()); + else api.createChangelog(this.deserializeLines(), author); } catch (Throwable e) { throw new CommitException("internal_error"); } @@ -93,7 +87,7 @@ public Edit(@NotNull ChangelogEntry entry) { } @Override - public void commit(@NotNull ChangelogStorage storage) throws CommitException { + public void commit(@NotNull ChangelogStorage storage, @NotNull ServerChangelogsApi api) throws CommitException { ChangelogEntry currentEntry = storage.getByUID(getEntryUID()); if (currentEntry == null) throw new CommitException("entry_deleted"); ChangelogEntry newEntry = new ChangelogEntry( diff --git a/base/src/test/java/com/codingcat/changelogs/base/api/ChangelogEventDispatcherTest.java b/base/src/test/java/com/codingcat/changelogs/base/api/ChangelogEventDispatcherTest.java new file mode 100644 index 0000000..c5c77ea --- /dev/null +++ b/base/src/test/java/com/codingcat/changelogs/base/api/ChangelogEventDispatcherTest.java @@ -0,0 +1,58 @@ +package com.codingcat.changelogs.base.api; + +import com.codingcat.changelogs.api.Changelog; +import com.codingcat.changelogs.api.ChangelogSubscription; +import com.codingcat.changelogs.api.event.ChangelogEvent; +import com.codingcat.changelogs.api.event.ChangelogPublishedEvent; +import com.codingcat.changelogs.api.event.ChangelogReadEvent; +import net.kyori.adventure.text.Component; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +class ChangelogEventDispatcherTest { + @Test + void dispatchesExactTypesAndSupportsIdempotentUnsubscribe() { + List handled = new ArrayList<>(); + ChangelogEventDispatcher dispatcher = new ChangelogEventDispatcher((_, _) -> { }); + ChangelogSubscription subscription = dispatcher.subscribe(ChangelogPublishedEvent.class, handled::add); + Changelog changelog = changelog(); + + dispatcher.dispatch(new ChangelogReadEvent(changelog, UUID.randomUUID())); + ChangelogPublishedEvent published = new ChangelogPublishedEvent(changelog); + dispatcher.dispatch(published); + subscription.unsubscribe(); + subscription.unsubscribe(); + dispatcher.dispatch(published); + + assertEquals(List.of(published), handled); + } + + @Test + void isolatesFailingListeners() { + List failures = new ArrayList<>(); + List handled = new ArrayList<>(); + ChangelogEventDispatcher dispatcher = new ChangelogEventDispatcher((_, error) -> failures.add(error)); + RuntimeException failure = new RuntimeException("listener failed"); + dispatcher.subscribe(ChangelogPublishedEvent.class, _ -> { throw failure; }); + dispatcher.subscribe(ChangelogPublishedEvent.class, handled::add); + ChangelogPublishedEvent event = new ChangelogPublishedEvent(changelog()); + + dispatcher.dispatch(event); + + assertEquals(List.of(event), handled); + assertEquals(1, failures.size()); + assertSame(failure, failures.getFirst()); + } + + private static Changelog changelog() { + return new Changelog(0, List.of(Component.text("Changes")), Instant.EPOCH, Optional.empty()); + } +} diff --git a/base/src/test/java/com/codingcat/changelogs/base/api/ChangelogServiceTest.java b/base/src/test/java/com/codingcat/changelogs/base/api/ChangelogServiceTest.java new file mode 100644 index 0000000..b80c5f2 --- /dev/null +++ b/base/src/test/java/com/codingcat/changelogs/base/api/ChangelogServiceTest.java @@ -0,0 +1,124 @@ +package com.codingcat.changelogs.base.api; + +import com.codingcat.changelogs.api.Changelog; +import com.codingcat.changelogs.api.event.ChangelogEvent; +import com.codingcat.changelogs.api.event.ChangelogPublishedEvent; +import com.codingcat.changelogs.api.event.ChangelogReadEvent; +import com.codingcat.changelogs.base.data.ChangelogEntry; +import com.codingcat.changelogs.base.data.ChangelogStorage; +import net.kyori.adventure.text.Component; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ChangelogServiceTest { + @Test + void publishesImmutableSnapshotsAndReportsLatest() { + InMemoryStorage storage = new InMemoryStorage(); + List events = new ArrayList<>(); + ChangelogService service = new ChangelogService(() -> storage, events::add); + + Changelog first = service.create(List.of(Component.text("First")), null); + Changelog second = service.create(List.of(Component.text("Second")), Component.text("Author")); + + assertEquals(0, first.id()); + assertEquals(second, service.getLatest().orElseThrow()); + assertEquals(List.of(first, second), service.getAll()); + assertEquals(Component.text("Author"), second.author().orElseThrow()); + assertThrows(UnsupportedOperationException.class, () -> first.lines().add(Component.empty())); + assertEquals(2, events.size()); + assertInstanceOf(ChangelogPublishedEvent.class, events.getFirst()); + } + + @Test + void emitsReadEventOnlyForFirstRead() { + InMemoryStorage storage = new InMemoryStorage(); + List events = new ArrayList<>(); + ChangelogService service = new ChangelogService(() -> storage, events::add); + Changelog changelog = service.create(List.of(Component.text("Changes")), null); + UUID playerId = UUID.randomUUID(); + events.clear(); + + assertFalse(service.hasRead(changelog.id(), playerId)); + assertTrue(service.markAsRead(changelog.id(), playerId)); + assertTrue(service.hasRead(changelog.id(), playerId)); + assertFalse(service.markAsRead(changelog.id(), playerId)); + assertFalse(service.markAsRead(99, playerId)); + + assertEquals(1, events.size()); + ChangelogReadEvent event = assertInstanceOf(ChangelogReadEvent.class, events.getFirst()); + assertEquals(changelog, event.changelog()); + assertEquals(playerId, event.playerId()); + } + + private static final class InMemoryStorage implements ChangelogStorage { + private final List entries = new ArrayList<>(); + + @Override + public void init() { + } + + @Override + public void shutdown() { + } + + @Override + public void storeEntry(ChangelogEntry entry) { + this.entries.add(entry.uid(), entry); + } + + @Override + public void updateEntry(ChangelogEntry entry) { + this.entries.set(entry.uid(), entry); + } + + @Override + public boolean removeEntry(int uid) { + return false; + } + + @Override + public List listEntries() { + return this.entries; + } + + @Override + public ChangelogEntry getByUID(int uid) { + return uid >= 0 && uid < this.entries.size() ? this.entries.get(uid) : null; + } + + @Override + public void markAsRead(int uid, UUID player) { + this.entries.get(uid).playersRead().add(player); + } + + @Override + public Instant getFirstSeenAt(UUID player) { + return null; + } + + @Override + public Instant recordFirstSeen(UUID player, Instant seenAt) { + return seenAt; + } + + @Override + public int nextUID() { + return this.entries.size(); + } + + @Override + public String getDisplayName() { + return "In-memory"; + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 968f37d..1c16e23 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,7 @@ [versions] adventure = "5.2.0" +junit-jupiter = "5.13.4" +junit-platform = "1.13.4" lombok = "9.7.0" mojang-brigadier = "1.3.10" packetevents = "2.14.0" @@ -16,6 +18,8 @@ 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" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit-platform" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } mojang-brigadier = { module = "com.mojang:brigadier", version.ref = "mojang-brigadier" } packetevents-api = { module = "com.github.retrooper:packetevents-api", version.ref = "packetevents" } diff --git a/settings.gradle.kts b/settings.gradle.kts index f5c76ca..bcb64c1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,2 @@ rootProject.name = "ServerChangelogs" -include("base", "platformapi", "paper", "velocity") \ No newline at end of file +include("api", "base", "platformapi", "paper", "velocity")