feat(notifications): add login quest counts and claim reminders
Release / release (push) Successful in 2m40s
CI / build (push) Successful in 1m17s

This commit is contained in:
dmg
2026-09-06 21:34:24 -04:00
parent 02f74d1822
commit de8ff83c3c
8 changed files with 130 additions and 4 deletions
+2
View File
@@ -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. 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 ## Explore
- [User stories](user-stories/index.md) - [User stories](user-stories/index.md)
+7
View File
@@ -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. - 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. - 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`. - 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`.
+2 -2
View File
@@ -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) 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) 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) 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) 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) 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) 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) 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) 10. [US-010: Generate a physical quest-board structure](us-010-generate-a-physical-quest-board.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] 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] 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 ## Related
- [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md) - [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.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] 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] 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 ## Related
- [US-005: Deliver blocks and complete a quest](us-005-deliver-blocks-and-complete-a-quest.md) - [US-005: Deliver blocks and complete a quest](us-005-deliver-blocks-and-complete-a-quest.md)
@@ -1,6 +1,7 @@
package games.dmg.spigotquestboard; package games.dmg.spigotquestboard;
import java.io.IOException; import java.io.IOException;
import java.time.Clock;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import java.util.logging.Level; import java.util.logging.Level;
@@ -15,11 +16,17 @@ final class BukkitIssuerNotifier implements IssuerNotifier, Listener {
private final QuestService quests; private final QuestService quests;
private final Server server; private final Server server;
private final Logger logger; private final Logger logger;
private final Clock clock;
BukkitIssuerNotifier(QuestService quests, Server server, Logger logger) { 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.quests = Objects.requireNonNull(quests, "quests");
this.server = Objects.requireNonNull(server, "server"); this.server = Objects.requireNonNull(server, "server");
this.logger = Objects.requireNonNull(logger, "logger"); this.logger = Objects.requireNonNull(logger, "logger");
this.clock = Objects.requireNonNull(clock, "clock");
} }
@Override @Override
@@ -32,7 +39,20 @@ final class BukkitIssuerNotifier implements IssuerNotifier, Listener {
@EventHandler @EventHandler
public void onPlayerJoin(PlayerJoinEvent event) { 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) { void deliver(Player player) {
@@ -41,7 +41,7 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
); );
Clock clock = Clock.systemUTC(); Clock clock = Clock.systemUTC();
BukkitIssuerNotifier notifier = new BukkitIssuerNotifier( BukkitIssuerNotifier notifier = new BukkitIssuerNotifier(
quests, getServer(), getLogger() quests, getServer(), getLogger(), clock
); );
QuestCompletionGateway completer = new QuestCompletionController( QuestCompletionGateway completer = new QuestCompletionController(
quests, new BukkitQuestCompletionInventory(), notifier quests, new BukkitQuestCompletionInventory(), notifier
@@ -4,15 +4,21 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.mock; 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.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import java.time.Clock;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.logging.Logger; import java.util.logging.Logger;
import org.bukkit.Server; import org.bukkit.Server;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
final class BukkitIssuerNotifierTest { final class BukkitIssuerNotifierTest {
@@ -82,6 +88,90 @@ final class BukkitIssuerNotifierTest {
assertTrue(service.state().notifications().isEmpty()); 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 { private static QuestService completedService(Repository repository) throws Exception {
QuestService service = new QuestService(repository); QuestService service = new QuestService(repository);
Quest quest = service.create( Quest quest = service.create(