From 2664f7c68b139405c0c4e93ef0fb74a442d72aee Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Tue, 11 Aug 2026 17:33:54 -0400 Subject: [PATCH] feat(notification): announce earned tree unlocks --- design/log.md | 7 ++ .../us-006-announce-tree-unlocks.md | 16 ++--- .../treefeller/CompositeProgressObserver.java | 18 +++++ .../dmg/treefeller/TreeFellerPlugin.java | 4 +- .../treefeller/TreeUnlockAnnouncement.java | 36 ++++++++++ .../TreeUnlockAnnouncementTest.java | 70 +++++++++++++++++++ 6 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 src/main/java/games/dmg/treefeller/CompositeProgressObserver.java create mode 100644 src/main/java/games/dmg/treefeller/TreeUnlockAnnouncement.java create mode 100644 src/test/java/games/dmg/treefeller/TreeUnlockAnnouncementTest.java diff --git a/design/log.md b/design/log.md index 9924f15..a3715d6 100644 --- a/design/log.md +++ b/design/log.md @@ -2,6 +2,13 @@ ## 2026-08-11 +### US-006 unlock announcement checkpoint + +- Added configurable, placeholder-aware titles, subtitles, timing, and chat guidance for newly earned species. +- Progress presentation now removes the completed boss bar before showing the one-time achievement and guidance about sneaking and undo. +- Verified earned and ordinary progress paths plus the complete build with `./gradlew clean check jar`. +- US-006 remains in progress until US-007 verifies distinct online messaging for administrative grants. + ### US-005 safe undo completed - Added one runtime-only latest-felling record per player with original world, coordinates, material, block-data string, and completion time. diff --git a/design/user-stories/us-006-announce-tree-unlocks.md b/design/user-stories/us-006-announce-tree-unlocks.md index ba5d40c..ebeaee3 100644 --- a/design/user-stories/us-006-announce-tree-unlocks.md +++ b/design/user-stories/us-006-announce-tree-unlocks.md @@ -2,7 +2,7 @@ type: User Story title: "US-006: Announce tree unlocks" description: Celebrate each newly earned species and explain how to control or undo automatic felling. -status: backlog +status: in-progress --- # US-006: Announce tree unlocks @@ -11,14 +11,14 @@ As a **player**, I want visible and actionable feedback when I unlock a species ## Acceptance criteria -- [ ] Earning a species unlock displays a configurable on-screen title and subtitle naming the species. -- [ ] Earning a species unlock also sends a configurable chat message that explains that sneaking prevents automatic felling and names `/treefeller undo`. -- [ ] Title text, subtitle text, fade-in time, display time, fade-out time, and chat text are configurable. -- [ ] Messages support the project's chosen Spigot formatting convention and a documented species placeholder. -- [ ] The boss bar for the newly unlocked species is removed when its unlock announcement is shown. -- [ ] An earned species produces its unlock announcement exactly once unless an administrator later resets that species and the player earns it again. +- [x] Earning a species unlock displays a configurable on-screen title and subtitle naming the species. +- [x] Earning a species unlock also sends a configurable chat message that explains that sneaking prevents automatic felling and names `/treefeller undo`. +- [x] Title text, subtitle text, fade-in time, display time, fade-out time, and chat text are configurable. +- [x] Messages support the project's chosen Spigot formatting convention and a documented species placeholder. +- [x] The boss bar for the newly unlocked species is removed when its unlock announcement is shown. +- [x] An earned species produces its unlock announcement exactly once unless an administrator later resets that species and the player earns it again. - [ ] An administrative grant clearly informs an online target that access was granted but does not falsely present it as a mined-block achievement. -- [ ] Invalid or cancelled breaks never generate an unlock announcement. +- [x] Invalid or cancelled breaks never generate an unlock announcement. ## Related diff --git a/src/main/java/games/dmg/treefeller/CompositeProgressObserver.java b/src/main/java/games/dmg/treefeller/CompositeProgressObserver.java new file mode 100644 index 0000000..ee58922 --- /dev/null +++ b/src/main/java/games/dmg/treefeller/CompositeProgressObserver.java @@ -0,0 +1,18 @@ +package games.dmg.treefeller; + +import java.util.List; +import org.bukkit.entity.Player; + +/** Delivers one persisted progress update to each presentation observer in order. */ +public final class CompositeProgressObserver implements ProgressObserver { + private final List observers; + + public CompositeProgressObserver(ProgressObserver... observers) { + this.observers = List.of(observers); + } + + @Override + public void onProgress(Player player, ProgressUpdate update) { + observers.forEach(observer -> observer.onProgress(player, update)); + } +} diff --git a/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java b/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java index c3d5f4f..7e40204 100644 --- a/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java +++ b/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java @@ -42,7 +42,9 @@ public final class TreeFellerPlugin extends JavaPlugin { playerStateRepository, species -> settingsService.current().threshold(species), automaticBreakRegistry, - progressBossBarObserver, + new CompositeProgressObserver( + progressBossBarObserver, + new TreeUnlockAnnouncement(settingsService::current)), exception -> getLogger().log( Level.SEVERE, "Unable to persist Tree Feller progress", exception)); getServer().getPluginManager().registerEvents(progressListener, this); diff --git a/src/main/java/games/dmg/treefeller/TreeUnlockAnnouncement.java b/src/main/java/games/dmg/treefeller/TreeUnlockAnnouncement.java new file mode 100644 index 0000000..3f215dc --- /dev/null +++ b/src/main/java/games/dmg/treefeller/TreeUnlockAnnouncement.java @@ -0,0 +1,36 @@ +package games.dmg.treefeller; + +import java.util.function.Supplier; +import org.bukkit.entity.Player; + +/** Presents the one-time earned unlock title and safety guidance. */ +public final class TreeUnlockAnnouncement implements ProgressObserver { + private final Supplier settings; + + public TreeUnlockAnnouncement(Supplier settings) { + this.settings = settings; + } + + @Override + public void onProgress(Player player, ProgressUpdate update) { + if (!update.newlyUnlocked()) { + return; + } + TreeFellerSettings current = settings.get(); + player.sendTitle( + format(current.titleText(), update), + format(current.subtitleText(), update), + current.titleFadeInTicks(), + current.titleStayTicks(), + current.titleFadeOutTicks()); + player.sendMessage(format(current.message("unlock-guidance"), update)); + } + + private String format(String template, ProgressUpdate update) { + return template + .replace("{species}", update.species().displayName()) + .replace("{progress}", Long.toString(update.progress())) + .replace("{threshold}", Integer.toString(update.threshold())) + .replace('&', '\u00a7'); + } +} diff --git a/src/test/java/games/dmg/treefeller/TreeUnlockAnnouncementTest.java b/src/test/java/games/dmg/treefeller/TreeUnlockAnnouncementTest.java new file mode 100644 index 0000000..287baf7 --- /dev/null +++ b/src/test/java/games/dmg/treefeller/TreeUnlockAnnouncementTest.java @@ -0,0 +1,70 @@ +package games.dmg.treefeller; + +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +class TreeUnlockAnnouncementTest { + @Test + void celebratesANewUnlockAndExplainsSneakingAndUndo() throws Exception { + TreeFellerSettings settings = defaults(); + Player player = mock(Player.class); + PlayerTreeFellerState state = PlayerTreeFellerState.initial(UUID.randomUUID(), "Player") + .withUnlocked(TreeSpecies.DARK_OAK, true); + TreeUnlockAnnouncement announcement = new TreeUnlockAnnouncement(() -> settings); + + announcement.onProgress(player, new ProgressUpdate( + state, TreeSpecies.DARK_OAK, 100, 100, true)); + + verify(player).sendTitle( + "§aDark Oak unlocked!", + "§fYou can now fell this tree type.", + 10, + 70, + 20); + verify(player).sendMessage(contains("Sneak")); + verify(player).sendMessage(contains("/treefeller undo")); + } + + @Test + void doesNotAnnounceOrdinaryProgressOrAnExistingUnlock() throws Exception { + Player player = mock(Player.class); + PlayerTreeFellerState state = PlayerTreeFellerState.initial(UUID.randomUUID(), "Player"); + TreeUnlockAnnouncement announcement = new TreeUnlockAnnouncement(this::uncheckedDefaults); + + announcement.onProgress(player, new ProgressUpdate( + state, TreeSpecies.OAK, 5, 100, false)); + + verify(player, never()).sendTitle( + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.anyInt()); + verify(player, never()).sendMessage(org.mockito.ArgumentMatchers.anyString()); + } + + private TreeFellerSettings uncheckedDefaults() { + try { + return defaults(); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + + private TreeFellerSettings defaults() throws Exception { + try (InputStreamReader reader = new InputStreamReader( + getClass().getClassLoader().getResourceAsStream("config.yml"), + StandardCharsets.UTF_8)) { + return TreeFellerSettings.load(YamlConfiguration.loadConfiguration(reader)); + } + } +}