From de8ff83c3c1439e3460efbd1043182a226ea6569 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sun, 6 Sep 2026 21:34:24 -0400 Subject: [PATCH] feat(notifications): add login quest counts and claim reminders --- knowledge/index.md | 2 + knowledge/log.md | 7 ++ knowledge/user-stories/index.md | 4 +- .../us-004-browse-available-quests.md | 3 + ...-007-expire-quests-and-claim-held-items.md | 4 + .../BukkitIssuerNotifier.java | 22 ++++- .../SpigotQuestBoardPlugin.java | 2 +- .../BukkitIssuerNotifierTest.java | 90 +++++++++++++++++++ 8 files changed, 130 insertions(+), 4 deletions(-) diff --git a/knowledge/index.md b/knowledge/index.md index 33a95f5..068cf50 100644 --- a/knowledge/index.md +++ b/knowledge/index.md @@ -9,6 +9,8 @@ okf_version: "0.1" This bundle specifies shared physical quest boards, reward escrow, block deliveries, item claims, player commands, administration, persistence, and plugin delivery requirements. +Login guidance is covered by [quest browsing](user-stories/us-004-browse-available-quests.md) and [recurring pending-item reminders](user-stories/us-007-expire-quests-and-claim-held-items.md). + ## Explore - [User stories](user-stories/index.md) diff --git a/knowledge/log.md b/knowledge/log.md index ef5210d..d26c0bf 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -119,3 +119,10 @@ description: Chronological record of material decisions affecting Spigot Quest B - Stored safe plain-text custom names alongside unchanged exact item metadata and inferred names from valid historical escrow data when possible. - Kept malformed or unavailable metadata from blocking quest browsing by falling back to material descriptions. - Verified 128 tests and the plugin JAR with `./gradlew clean check jar`. + +## 2026-09-07T01:31:27Z — Login quest guidance and recurring claim reminders + +- Extended [US-004](user-stories/us-004-browse-available-quests.md) with a private active, unexpired quest count on every login and guidance to visit the quest board to accept or create a quest. +- Extended [US-007](user-stories/us-007-expire-quests-and-claim-held-items.md) with reminders on every login while actual pending claims remain, independently of notification acknowledgement and across reloads. +- Preserved existing real-time and durable notifications without changing claim storage or collection. +- Confirmed new behavior tests failed before implementation, then verified all 130 tests and the plugin JAR with `./gradlew clean check jar`. diff --git a/knowledge/user-stories/index.md b/knowledge/user-stories/index.md index 6ce3a04..239f8fb 100644 --- a/knowledge/user-stories/index.md +++ b/knowledge/user-stories/index.md @@ -9,10 +9,10 @@ description: Catalog of user stories for the Spigot Quest Board plugin. 1. [US-001: Build and release the plugin](us-001-build-and-release-plugin.md) 2. [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md) 3. [US-003: Create a block-delivery quest](us-003-create-a-block-delivery-quest.md) -4. [US-004: Browse available quests](us-004-browse-available-quests.md) +4. [US-004: Browse available quests](us-004-browse-available-quests.md) — includes login quest counts and board guidance. 5. [US-005: Deliver blocks and complete a quest](us-005-deliver-blocks-and-complete-a-quest.md) 6. [US-006: Cancel an owned quest](us-006-cancel-an-owned-quest.md) -7. [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md) +7. [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md) — includes recurring login reminders for unclaimed items. 8. [US-008: Control player quest commands](us-008-control-player-quest-commands.md) 9. [US-009: Use a screen-fitting quest-board interface](us-009-use-a-screen-fitting-quest-board-interface.md) 10. [US-010: Generate a physical quest-board structure](us-010-generate-a-physical-quest-board.md) diff --git a/knowledge/user-stories/us-004-browse-available-quests.md b/knowledge/user-stories/us-004-browse-available-quests.md index bbd3d45..613f181 100644 --- a/knowledge/user-stories/us-004-browse-available-quests.md +++ b/knowledge/user-stories/us-004-browse-available-quests.md @@ -19,6 +19,9 @@ As a **player**, I want to browse current quests so that I can decide which bloc - [x] Listing and autocomplete do not expose stale quest identifiers as completable or cancellable. - [x] Automated tests verify filtering, displayed fields, duration boundaries, and command aliases. +- [x] Every login privately reports the global active, unexpired quest count with natural zero and singular wording, followed by “Visit the quest board to accept or create a quest.” +- [x] Automated tests verify repeated login summaries, count wording, and exclusion of completed, cancelled, and expired quests. + ## Related - [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md) diff --git a/knowledge/user-stories/us-007-expire-quests-and-claim-held-items.md b/knowledge/user-stories/us-007-expire-quests-and-claim-held-items.md index 63bb070..266d60e 100644 --- a/knowledge/user-stories/us-007-expire-quests-and-claim-held-items.md +++ b/knowledge/user-stories/us-007-expire-quests-and-claim-held-items.md @@ -24,6 +24,10 @@ As a **quest issuer**, I want delivered blocks and returned rewards held at the - [x] Pending claims and notification state survive logout and server restart without duplication or loss. - [x] Automated tests verify expiry boundaries, each claim source, notifications, overflow, failure recovery, and persistence. +- [x] Every login with pending claims shows “You have items waiting to be claimed. Visit the quest board to collect them.” independently of previous notification delivery. +- [x] Reminders stop once all items are claimed, preserve real-time notifications, and do not change claims or item storage. +- [x] Automated tests verify recurring reminders across logins and reloads, isolation by player, and silence after collection. + ## Related - [US-005: Deliver blocks and complete a quest](us-005-deliver-blocks-and-complete-a-quest.md) diff --git a/src/main/java/games/dmg/spigotquestboard/BukkitIssuerNotifier.java b/src/main/java/games/dmg/spigotquestboard/BukkitIssuerNotifier.java index 11e197a..9bdfef7 100644 --- a/src/main/java/games/dmg/spigotquestboard/BukkitIssuerNotifier.java +++ b/src/main/java/games/dmg/spigotquestboard/BukkitIssuerNotifier.java @@ -1,6 +1,7 @@ package games.dmg.spigotquestboard; import java.io.IOException; +import java.time.Clock; import java.util.Objects; import java.util.UUID; import java.util.logging.Level; @@ -15,11 +16,17 @@ final class BukkitIssuerNotifier implements IssuerNotifier, Listener { private final QuestService quests; private final Server server; private final Logger logger; + private final Clock clock; BukkitIssuerNotifier(QuestService quests, Server server, Logger logger) { + this(quests, server, logger, Clock.systemUTC()); + } + + BukkitIssuerNotifier(QuestService quests, Server server, Logger logger, Clock clock) { this.quests = Objects.requireNonNull(quests, "quests"); this.server = Objects.requireNonNull(server, "server"); this.logger = Objects.requireNonNull(logger, "logger"); + this.clock = Objects.requireNonNull(clock, "clock"); } @Override @@ -32,7 +39,20 @@ final class BukkitIssuerNotifier implements IssuerNotifier, Listener { @EventHandler public void onPlayerJoin(PlayerJoinEvent event) { - deliver(event.getPlayer()); + Player player = event.getPlayer(); + int count = quests.activeQuests(clock.instant()).size(); + String summary = switch (count) { + case 0 -> "No quests are currently available."; + case 1 -> "1 quest is currently available."; + default -> count + " quests are currently available."; + }; + player.sendMessage(summary + " Visit the quest board to accept or create a quest."); + if (!quests.claimsFor(player.getUniqueId()).isEmpty()) { + player.sendMessage( + "You have items waiting to be claimed. Visit the quest board to collect them." + ); + } + deliver(player); } void deliver(Player player) { diff --git a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java index c5059fe..670eda3 100644 --- a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java +++ b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java @@ -41,7 +41,7 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin { ); Clock clock = Clock.systemUTC(); BukkitIssuerNotifier notifier = new BukkitIssuerNotifier( - quests, getServer(), getLogger() + quests, getServer(), getLogger(), clock ); QuestCompletionGateway completer = new QuestCompletionController( quests, new BukkitQuestCompletionInventory(), notifier diff --git a/src/test/java/games/dmg/spigotquestboard/BukkitIssuerNotifierTest.java b/src/test/java/games/dmg/spigotquestboard/BukkitIssuerNotifierTest.java index 1227e76..5a55f50 100644 --- a/src/test/java/games/dmg/spigotquestboard/BukkitIssuerNotifierTest.java +++ b/src/test/java/games/dmg/spigotquestboard/BukkitIssuerNotifierTest.java @@ -4,15 +4,21 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.contains; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.time.Clock; import java.time.Instant; +import java.time.ZoneOffset; import java.util.List; import java.util.UUID; import java.util.logging.Logger; import org.bukkit.Server; import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerJoinEvent; import org.junit.jupiter.api.Test; final class BukkitIssuerNotifierTest { @@ -82,6 +88,90 @@ final class BukkitIssuerNotifierTest { assertTrue(service.state().notifications().isEmpty()); } + @Test + void everyLoginReportsAvailableQuestsWithNaturalWordingAndBoardHint() throws Exception { + QuestService service = new QuestService(new Repository()); + Instant now = Instant.EPOCH.plusSeconds(604800); + BukkitIssuerNotifier notifier = new BukkitIssuerNotifier( + service, mock(Server.class), Logger.getAnonymousLogger(), + Clock.fixed(now, ZoneOffset.UTC) + ); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + PlayerJoinEvent event = mock(PlayerJoinEvent.class); + when(event.getPlayer()).thenReturn(player); + String hint = " Visit the quest board to accept or create a quest."; + + // Expired at exactly login time, even before the expiry task runs. + createQuest(service, Instant.EPOCH); + Quest completed = createQuest(service, now); + service.complete(completed.id(), new EscrowItem("STONE", 1, null), now); + Quest cancelled = createQuest(service, now); + service.cancel(cancelled.id(), cancelled.issuerId(), now); + notifier.onPlayerJoin(event); + verify(player).sendMessage("No quests are currently available." + hint); + + createQuest(service, now); + notifier.onPlayerJoin(event); + verify(player).sendMessage("1 quest is currently available." + hint); + + createQuest(service, now); + notifier.onPlayerJoin(event); + notifier.onPlayerJoin(event); + verify(player, times(2)).sendMessage("2 quests are currently available." + hint); + verify(player, never()).sendMessage(contains("items waiting")); + } + + @Test + void claimsAreRemindedOnEveryLoginAcrossReloadsUntilAllCollected() throws Exception { + Repository repository = new Repository(); + QuestService service = completedService(repository); + UUID issuer = service.state().notifications().values().iterator().next().recipientId(); + Quest cancelled = service.create( + issuer, "Issuer", "STONE", 1, + List.of(new EscrowItem("DIAMOND", 1, null)), Instant.EPOCH + ); + service.cancel(cancelled.id(), issuer, Instant.EPOCH.plusSeconds(1)); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(issuer); + PlayerJoinEvent event = mock(PlayerJoinEvent.class); + when(event.getPlayer()).thenReturn(player); + String reminder = "You have items waiting to be claimed. " + + "Visit the quest board to collect them."; + BukkitIssuerNotifier notifier = new BukkitIssuerNotifier( + service, mock(Server.class), Logger.getAnonymousLogger() + ); + notifier.onPlayerJoin(event); + notifier.onPlayerJoin(event); + verify(player, times(2)).sendMessage(reminder); + assertTrue(service.pendingNotifications(issuer).isEmpty()); + assertEquals(2, service.claimsFor(issuer).size()); + + service = new QuestService(repository); + notifier = new BukkitIssuerNotifier(service, mock(Server.class), Logger.getAnonymousLogger()); + notifier.onPlayerJoin(event); + verify(player, times(3)).sendMessage(reminder); + service.acknowledgeClaim(issuer, service.claimsFor(issuer).getFirst().id()); + notifier.onPlayerJoin(event); + verify(player, times(4)).sendMessage(reminder); + + clearInvocations(player); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + notifier.onPlayerJoin(event); + verify(player, never()).sendMessage(reminder); + when(player.getUniqueId()).thenReturn(issuer); + service.acknowledgeClaim(issuer, service.claimsFor(issuer).getFirst().id()); + notifier.onPlayerJoin(event); + verify(player, never()).sendMessage(reminder); + } + + private static Quest createQuest(QuestService service, Instant now) throws Exception { + return service.create( + UUID.randomUUID(), "Issuer", "STONE", 1, + List.of(new EscrowItem("DIAMOND", 1, null)), now + ); + } + private static QuestService completedService(Repository repository) throws Exception { QuestService service = new QuestService(repository); Quest quest = service.create(