From dacdcb7aa6cd28a0c44b56cc4232ddffc6cb1d5f Mon Sep 17 00:00:00 2001 From: TiggerTakkyTTT Date: Sat, 26 Sep 2026 11:27:26 +0700 Subject: [PATCH 1/4] Store player first-seen timestamps --- README.md | 2 +- base/build.gradle.kts | 10 ++- .../base/data/ChangelogStorage.java | 12 +++ .../data/storage/YamlChangelogStorage.java | 47 ++++++++++- .../base/dialog/ui/ChangelogDialog.java | 4 +- .../base/event/ChangelogJoinListener.java | 12 ++- .../storage/YamlChangelogStorageTest.java | 82 +++++++++++++++++++ gradle/libs.versions.toml | 4 +- 8 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 base/src/test/java/com/codingcat/changelogs/base/data/storage/YamlChangelogStorageTest.java diff --git a/README.md b/README.md index bf6d6ab..d601c8c 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ The roadmap below contains some of the features that I'm **planning to add** in - [ ] **Pre-defined authors**: Rather than having to enter the name of the changelog's author every time (possibly including formatting), there could be a list of pre-defined authors in the plugin config that a user could choose from. - [ ] **Customizable changelog dialog layout**: Currently, the changelog dialog always shows all previous changelogs, with unread ones marked red. In the future, it'd be nice to have an option for this, e.g. so players on join could also only see the most recent changelog. - [ ] **Paged changelogs**: At the moment, all existing changelogs are just added to the dialog without any additional checks. While this is fine with just a few of them, it could quickly become an issue with a long history of changelogs. So adding a page system with a configurable amount of changelogs per page would be great! -- [ ] **Player first-join storage**: Right now, changelogs are just displayed to players upon joining whenever they haven't read them (except for the **first time** a player joins a server). This can be very confusing to new-ish players, since they weren't there for all the previous changes to begin with! A possible (while not 100% perfect) solution to this would be storing when the player initially joined the server / was first seen by the plugin, to then only display relevant changelogs to them. +- [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! diff --git a/base/build.gradle.kts b/base/build.gradle.kts index a13c2ae..f052855 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -15,8 +15,16 @@ dependencies { compileOnly(libs.adventure.minimessage) compileOnly(libs.adventure.gson) compileOnly(libs.packetevents.api) + testImplementation(libs.junit.jupiter) + testImplementation(libs.adventure.api) + testRuntimeOnly(libs.snakeyaml) + testRuntimeOnly(libs.junit.platform.launcher) } java { toolchain.languageVersion = JavaLanguageVersion.of(25) -} \ No newline at end of file +} + +tasks.test { + useJUnitPlatform() +} 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 index 5facc95..f9bf608 100644 --- a/base/src/main/java/com/codingcat/changelogs/base/data/ChangelogStorage.java +++ b/base/src/main/java/com/codingcat/changelogs/base/data/ChangelogStorage.java @@ -5,6 +5,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.time.Instant; import java.util.List; import java.util.UUID; @@ -25,6 +26,17 @@ public interface ChangelogStorage { void markAsRead(int uid, @NotNull UUID player); + @Nullable Instant getFirstSeenAt(@NotNull UUID player); + + boolean 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(); 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 index 945aa3a..ba1244b 100644 --- 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 @@ -21,11 +21,13 @@ public class YamlChangelogStorage implements ChangelogStorage { 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; } @@ -33,6 +35,7 @@ public void init() { 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); } @@ -66,11 +69,19 @@ public boolean removeEntry(int uid) { private void save() { Yaml config = new Yaml(); - List> data = this.cache.stream() + List> entries = this.cache.stream() .map(this::serializeEntry) .toList(); + Map firstSeen = this.playerFirstSeen.entrySet().stream() + .collect(Collectors.toMap( + entry -> entry.getKey().toString(), + entry -> DateTimeFormatter.ISO_INSTANT.format(entry.getValue()) + )); + Map data = new LinkedHashMap<>(); + data.put("entries", entries); + data.put("playerFirstSeen", firstSeen); try (FileOutputStream os = new FileOutputStream(filePath.toFile())) { - config.dump(Map.of("entries", data), new BufferedWriter(new OutputStreamWriter(os))); + config.dump(data, new BufferedWriter(new OutputStreamWriter(os))); } catch (IOException e) { throw new RuntimeException("Failed to save changelog data to " + filePath, e); } @@ -110,6 +121,26 @@ private void save() { return new ChangelogEntry(uid, lines, recordedAt, author, playersRead); } + private @NotNull Map deserializePlayerFirstSeen(@NotNull Map config) { + try { + Object rawFirstSeen = config.get("playerFirstSeen"); + if (rawFirstSeen == null) return new HashMap<>(); + if (!(rawFirstSeen instanceof Map firstSeen)) + throw new IllegalArgumentException("playerFirstSeen must be a map"); + Map results = new HashMap<>(); + firstSeen.forEach((rawPlayer, rawInstant) -> { + UUID player = UUID.fromString(Objects.requireNonNull(rawPlayer, "player UUID").toString()); + Instant seenAt = DateTimeFormatter.ISO_INSTANT.parse( + Objects.requireNonNull(rawInstant, "first-seen timestamp").toString(), Instant::from + ); + results.put(player, seenAt); + }); + return results; + } catch (Exception e) { + throw new RuntimeException("Failed to deserialize player first-seen data", e); + } + } + private @NotNull Map serializeEntry(@NotNull ChangelogEntry entry) { Map data = new HashMap<>(); List serializedLines = entry.lines() @@ -150,6 +181,18 @@ public void markAsRead(int uid, @NotNull UUID player) { this.save(); } + @Override + public @Nullable Instant getFirstSeenAt(@NotNull UUID player) { + return this.playerFirstSeen.get(player); + } + + @Override + public boolean recordFirstSeen(@NotNull UUID player, @NotNull Instant seenAt) { + if (this.playerFirstSeen.putIfAbsent(player, seenAt) != null) return false; + this.save(); + return true; + } + @Override public int nextUID() { return this.cache.size(); 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 f1909f2..288428a 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 @@ -85,7 +85,7 @@ public class ChangelogDialog implements IDialog { private @NotNull Component formatEntry(@NotNull ChangelogEntry entry, @NotNull IPlayer player, @NotNull DialogSessionManager sessionManager, boolean canManage) { Component linesComponent = createLinesComponent(player, null, entry.lines()); - Component entryComponent = translatableManual(player, "dialog.changelog.entry" + (!entry.hasRead(player) ? "_unread" : ""), + 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")) ); @@ -123,7 +123,7 @@ public void onActionTriggered(@NotNull String action, @Nullable NBTCompound data case "confirm_read" -> { List uids = this.storage.listEntries() .stream() - .filter(e -> !e.hasRead(source)) + .filter(e -> this.storage.isUnreadFor(e, source.getUniqueId())) .map(ChangelogEntry::uid) .toList(); uids.forEach(uid -> storage.markAsRead(uid, source.getUniqueId())); 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 index 794cc07..2723b1f 100644 --- a/base/src/main/java/com/codingcat/changelogs/base/event/ChangelogJoinListener.java +++ b/base/src/main/java/com/codingcat/changelogs/base/event/ChangelogJoinListener.java @@ -14,6 +14,7 @@ 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; @@ -45,9 +46,14 @@ public void unregisterEvents(@NotNull IEventManager manager) { } public boolean checkShowDialog(@NotNull IPlayer player, boolean freeze) { - if (player.isFirstJoin()) return false; - boolean unreadChangelogs = this.storageSupplier.get().listEntries().stream() - .anyMatch(e -> !e.hasRead(player)); + ChangelogStorage storage = this.storageSupplier.get(); + Instant firstSeenAt = storage.getFirstSeenAt(player.getUniqueId()); + if (firstSeenAt == null) { + storage.recordFirstSeen(player.getUniqueId(), Instant.now()); + return false; + } + boolean unreadChangelogs = storage.listEntries().stream() + .anyMatch(e -> storage.isUnreadFor(e, player.getUniqueId())); if (!unreadChangelogs) return false; ChangelogDialog dialog = this.dialogHolder.getFromType(ChangelogDialog.class); if (freeze) this.sessionManager.freeze(player); diff --git a/base/src/test/java/com/codingcat/changelogs/base/data/storage/YamlChangelogStorageTest.java b/base/src/test/java/com/codingcat/changelogs/base/data/storage/YamlChangelogStorageTest.java new file mode 100644 index 0000000..3998ad8 --- /dev/null +++ b/base/src/test/java/com/codingcat/changelogs/base/data/storage/YamlChangelogStorageTest.java @@ -0,0 +1,82 @@ +package com.codingcat.changelogs.base.data.storage; + +import com.codingcat.changelogs.base.data.ChangelogEntry; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class YamlChangelogStorageTest { + @TempDir + Path temporaryDirectory; + + @Test + void storesFirstSeenTimestampOnlyOnce() { + Path dataFile = this.temporaryDirectory.resolve("_data.yml"); + UUID player = UUID.randomUUID(); + Instant firstSeenAt = Instant.parse("2026-01-02T03:04:05Z"); + + YamlChangelogStorage storage = new YamlChangelogStorage(dataFile); + storage.init(); + + assertTrue(storage.recordFirstSeen(player, firstSeenAt)); + assertFalse(storage.recordFirstSeen(player, firstSeenAt.plusSeconds(30))); + assertEquals(firstSeenAt, storage.getFirstSeenAt(player)); + + YamlChangelogStorage reloadedStorage = new YamlChangelogStorage(dataFile); + reloadedStorage.init(); + assertEquals(firstSeenAt, reloadedStorage.getFirstSeenAt(player)); + } + + @Test + void addsFirstSeenDataToLegacyStorageFile() throws IOException { + Path dataFile = this.temporaryDirectory.resolve("_data.yml"); + Files.writeString(dataFile, "entries: []\n"); + UUID player = UUID.randomUUID(); + Instant firstSeenAt = Instant.parse("2026-02-03T04:05:06Z"); + + YamlChangelogStorage storage = new YamlChangelogStorage(dataFile); + storage.init(); + + assertNull(storage.getFirstSeenAt(player)); + assertTrue(storage.recordFirstSeen(player, firstSeenAt)); + + YamlChangelogStorage reloadedStorage = new YamlChangelogStorage(dataFile); + reloadedStorage.init(); + assertEquals(firstSeenAt, reloadedStorage.getFirstSeenAt(player)); + assertTrue(reloadedStorage.listEntries().isEmpty()); + } + + @Test + void onlyTreatsUnreadEntriesAfterFirstSeenAsUnread() { + Path dataFile = this.temporaryDirectory.resolve("_data.yml"); + UUID player = UUID.randomUUID(); + Instant firstSeenAt = Instant.parse("2026-03-04T05:06:07Z"); + YamlChangelogStorage storage = new YamlChangelogStorage(dataFile); + storage.init(); + storage.recordFirstSeen(player, firstSeenAt); + + ChangelogEntry older = entryAt(firstSeenAt.minusSeconds(1)); + ChangelogEntry simultaneous = entryAt(firstSeenAt); + ChangelogEntry newer = entryAt(firstSeenAt.plusSeconds(1)); + + assertFalse(storage.isUnreadFor(older, player)); + assertFalse(storage.isUnreadFor(simultaneous, player)); + assertTrue(storage.isUnreadFor(newer, player)); + + newer.playersRead().add(player); + assertFalse(storage.isUnreadFor(newer, player)); + } + + private static ChangelogEntry entryAt(Instant recordedAt) { + return new ChangelogEntry(0, List.of(), recordedAt, null, new HashSet<>()); + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 964ed51..1257801 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,9 +7,11 @@ slf4j-api = { group = "org.slf4j", name = "slf4j-api", version = "2.0.18" } mojang-brigadier = { group = "com.mojang", name = "brigadier", version = "1.3.10" } packetevents-api = { group = "com.github.retrooper", name = "packetevents-api", version = "2.13.0" } snakeyaml = { group = "org.yaml", name = "snakeyaml", version = "2.2" } +junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version = "5.11.4" } +junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version = "1.11.4" } packetevents-paper = { group = "com.github.retrooper", name = "packetevents-spigot", version = "2.13.0" } packetevents-velocity = { group = "com.github.retrooper", name = "packetevents-velocity", version = "2.13.0" } [plugins] lombok = { id = "io.freefair.lombok", version = "9.5.0"} -shadow = { id = "com.gradleup.shadow", version = "9.6.1"} \ No newline at end of file +shadow = { id = "com.gradleup.shadow", version = "9.6.1"} From 1e4c4fb41a457bbfaaae90cd383539602097acdb Mon Sep 17 00:00:00 2001 From: codingcat2468 Date: Sun, 27 Sep 2026 23:14:28 +0200 Subject: [PATCH 2/4] Revert storage test additions (for now) --- base/build.gradle.kts | 8 -- .../storage/YamlChangelogStorageTest.java | 82 ------------------- gradle/libs.versions.toml | 2 - 3 files changed, 92 deletions(-) delete mode 100644 base/src/test/java/com/codingcat/changelogs/base/data/storage/YamlChangelogStorageTest.java diff --git a/base/build.gradle.kts b/base/build.gradle.kts index f052855..dad8a5f 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -15,16 +15,8 @@ dependencies { compileOnly(libs.adventure.minimessage) compileOnly(libs.adventure.gson) compileOnly(libs.packetevents.api) - testImplementation(libs.junit.jupiter) - testImplementation(libs.adventure.api) - testRuntimeOnly(libs.snakeyaml) - testRuntimeOnly(libs.junit.platform.launcher) } java { toolchain.languageVersion = JavaLanguageVersion.of(25) } - -tasks.test { - useJUnitPlatform() -} diff --git a/base/src/test/java/com/codingcat/changelogs/base/data/storage/YamlChangelogStorageTest.java b/base/src/test/java/com/codingcat/changelogs/base/data/storage/YamlChangelogStorageTest.java deleted file mode 100644 index 3998ad8..0000000 --- a/base/src/test/java/com/codingcat/changelogs/base/data/storage/YamlChangelogStorageTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.codingcat.changelogs.base.data.storage; - -import com.codingcat.changelogs.base.data.ChangelogEntry; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; -import java.util.HashSet; -import java.util.List; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; - -class YamlChangelogStorageTest { - @TempDir - Path temporaryDirectory; - - @Test - void storesFirstSeenTimestampOnlyOnce() { - Path dataFile = this.temporaryDirectory.resolve("_data.yml"); - UUID player = UUID.randomUUID(); - Instant firstSeenAt = Instant.parse("2026-01-02T03:04:05Z"); - - YamlChangelogStorage storage = new YamlChangelogStorage(dataFile); - storage.init(); - - assertTrue(storage.recordFirstSeen(player, firstSeenAt)); - assertFalse(storage.recordFirstSeen(player, firstSeenAt.plusSeconds(30))); - assertEquals(firstSeenAt, storage.getFirstSeenAt(player)); - - YamlChangelogStorage reloadedStorage = new YamlChangelogStorage(dataFile); - reloadedStorage.init(); - assertEquals(firstSeenAt, reloadedStorage.getFirstSeenAt(player)); - } - - @Test - void addsFirstSeenDataToLegacyStorageFile() throws IOException { - Path dataFile = this.temporaryDirectory.resolve("_data.yml"); - Files.writeString(dataFile, "entries: []\n"); - UUID player = UUID.randomUUID(); - Instant firstSeenAt = Instant.parse("2026-02-03T04:05:06Z"); - - YamlChangelogStorage storage = new YamlChangelogStorage(dataFile); - storage.init(); - - assertNull(storage.getFirstSeenAt(player)); - assertTrue(storage.recordFirstSeen(player, firstSeenAt)); - - YamlChangelogStorage reloadedStorage = new YamlChangelogStorage(dataFile); - reloadedStorage.init(); - assertEquals(firstSeenAt, reloadedStorage.getFirstSeenAt(player)); - assertTrue(reloadedStorage.listEntries().isEmpty()); - } - - @Test - void onlyTreatsUnreadEntriesAfterFirstSeenAsUnread() { - Path dataFile = this.temporaryDirectory.resolve("_data.yml"); - UUID player = UUID.randomUUID(); - Instant firstSeenAt = Instant.parse("2026-03-04T05:06:07Z"); - YamlChangelogStorage storage = new YamlChangelogStorage(dataFile); - storage.init(); - storage.recordFirstSeen(player, firstSeenAt); - - ChangelogEntry older = entryAt(firstSeenAt.minusSeconds(1)); - ChangelogEntry simultaneous = entryAt(firstSeenAt); - ChangelogEntry newer = entryAt(firstSeenAt.plusSeconds(1)); - - assertFalse(storage.isUnreadFor(older, player)); - assertFalse(storage.isUnreadFor(simultaneous, player)); - assertTrue(storage.isUnreadFor(newer, player)); - - newer.playersRead().add(player); - assertFalse(storage.isUnreadFor(newer, player)); - } - - private static ChangelogEntry entryAt(Instant recordedAt) { - return new ChangelogEntry(0, List.of(), recordedAt, null, new HashSet<>()); - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1257801..f5a18c7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,8 +7,6 @@ slf4j-api = { group = "org.slf4j", name = "slf4j-api", version = "2.0.18" } mojang-brigadier = { group = "com.mojang", name = "brigadier", version = "1.3.10" } packetevents-api = { group = "com.github.retrooper", name = "packetevents-api", version = "2.13.0" } snakeyaml = { group = "org.yaml", name = "snakeyaml", version = "2.2" } -junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version = "5.11.4" } -junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version = "1.11.4" } packetevents-paper = { group = "com.github.retrooper", name = "packetevents-spigot", version = "2.13.0" } packetevents-velocity = { group = "com.github.retrooper", name = "packetevents-velocity", version = "2.13.0" } From c60de4743d664b3517c8d048ed1e4b49ea395ad7 Mon Sep 17 00:00:00 2001 From: codingcat2468 Date: Sun, 27 Sep 2026 23:46:48 +0200 Subject: [PATCH 3/4] Small code style changes / simplifications --- .../data/storage/YamlChangelogStorage.java | 41 +++++++++---------- 1 file changed, 19 insertions(+), 22 deletions(-) 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 index ba1244b..de662c8 100644 --- 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 @@ -19,6 +19,7 @@ @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; @@ -72,14 +73,9 @@ private void save() { List> entries = this.cache.stream() .map(this::serializeEntry) .toList(); - Map firstSeen = this.playerFirstSeen.entrySet().stream() - .collect(Collectors.toMap( - entry -> entry.getKey().toString(), - entry -> DateTimeFormatter.ISO_INSTANT.format(entry.getValue()) - )); Map data = new LinkedHashMap<>(); data.put("entries", entries); - data.put("playerFirstSeen", firstSeen); + data.put("playerFirstSeen", this.serializePlayerFirstSeen()); try (FileOutputStream os = new FileOutputStream(filePath.toFile())) { config.dump(data, new BufferedWriter(new OutputStreamWriter(os))); } catch (IOException e) { @@ -123,31 +119,32 @@ private void save() { private @NotNull Map deserializePlayerFirstSeen(@NotNull Map config) { try { - Object rawFirstSeen = config.get("playerFirstSeen"); + //noinspection unchecked + Map rawFirstSeen = (Map) config.get("playerFirstSeen"); if (rawFirstSeen == null) return new HashMap<>(); - if (!(rawFirstSeen instanceof Map firstSeen)) - throw new IllegalArgumentException("playerFirstSeen must be a map"); - Map results = new HashMap<>(); - firstSeen.forEach((rawPlayer, rawInstant) -> { - UUID player = UUID.fromString(Objects.requireNonNull(rawPlayer, "player UUID").toString()); - Instant seenAt = DateTimeFormatter.ISO_INSTANT.parse( - Objects.requireNonNull(rawInstant, "first-seen timestamp").toString(), Instant::from - ); - results.put(player, seenAt); - }); - return results; + 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 = DateTimeFormatter.ISO_INSTANT.format(entry.recordedAt()); + String recordedAtRaw = DATE_FORMATTER.format(entry.recordedAt()); data.put("recordedAt", recordedAtRaw); String serializedAuthor = Optional.ofNullable(entry.author()) .map(GsonComponentSerializer.gson()::serialize) @@ -188,9 +185,9 @@ public void markAsRead(int uid, @NotNull UUID player) { @Override public boolean recordFirstSeen(@NotNull UUID player, @NotNull Instant seenAt) { - if (this.playerFirstSeen.putIfAbsent(player, seenAt) != null) return false; - this.save(); - return true; + boolean recorded = this.playerFirstSeen.putIfAbsent(player, seenAt) != null; + if (recorded) this.save(); + return recorded; } @Override From a9a691467647cafd403b4629126bca4ea21188ad Mon Sep 17 00:00:00 2001 From: codingcat2468 Date: Sun, 27 Sep 2026 23:53:27 +0200 Subject: [PATCH 4/4] Avoid polling database for every changelog entry --- .../codingcat/changelogs/base/data/ChangelogStorage.java | 2 +- .../base/data/storage/YamlChangelogStorage.java | 8 ++++---- .../changelogs/base/event/ChangelogJoinListener.java | 8 ++------ 3 files changed, 7 insertions(+), 11 deletions(-) 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 index f9bf608..a5c5cbf 100644 --- a/base/src/main/java/com/codingcat/changelogs/base/data/ChangelogStorage.java +++ b/base/src/main/java/com/codingcat/changelogs/base/data/ChangelogStorage.java @@ -28,7 +28,7 @@ public interface ChangelogStorage { @Nullable Instant getFirstSeenAt(@NotNull UUID player); - boolean recordFirstSeen(@NotNull UUID player, @NotNull Instant seenAt); + @NotNull Instant recordFirstSeen(@NotNull UUID player, @NotNull Instant seenAt); default boolean isUnreadFor(@NotNull ChangelogEntry entry, @NotNull UUID player) { Instant firstSeenAt = this.getFirstSeenAt(player); 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 index de662c8..a5ed96b 100644 --- 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 @@ -184,10 +184,10 @@ public void markAsRead(int uid, @NotNull UUID player) { } @Override - public boolean recordFirstSeen(@NotNull UUID player, @NotNull Instant seenAt) { - boolean recorded = this.playerFirstSeen.putIfAbsent(player, seenAt) != null; - if (recorded) this.save(); - return recorded; + 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 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 index 2723b1f..84a6fd5 100644 --- a/base/src/main/java/com/codingcat/changelogs/base/event/ChangelogJoinListener.java +++ b/base/src/main/java/com/codingcat/changelogs/base/event/ChangelogJoinListener.java @@ -47,13 +47,9 @@ public void unregisterEvents(@NotNull IEventManager manager) { public boolean checkShowDialog(@NotNull IPlayer player, boolean freeze) { ChangelogStorage storage = this.storageSupplier.get(); - Instant firstSeenAt = storage.getFirstSeenAt(player.getUniqueId()); - if (firstSeenAt == null) { - storage.recordFirstSeen(player.getUniqueId(), Instant.now()); - return false; - } + Instant firstSeenAt = storage.recordFirstSeen(player.getUniqueId(), Instant.now()); boolean unreadChangelogs = storage.listEntries().stream() - .anyMatch(e -> storage.isUnreadFor(e, player.getUniqueId())); + .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);