feat(notification): announce earned tree unlocks

This commit is contained in:
dmg
2026-08-11 17:33:54 -04:00
parent 8c7c008ca0
commit 2664f7c68b
6 changed files with 142 additions and 9 deletions
+7
View File
@@ -2,6 +2,13 @@
## 2026-08-11 ## 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 ### 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. - Added one runtime-only latest-felling record per player with original world, coordinates, material, block-data string, and completion time.
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-006: Announce tree unlocks" title: "US-006: Announce tree unlocks"
description: Celebrate each newly earned species and explain how to control or undo automatic felling. 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 # 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 ## Acceptance criteria
- [ ] Earning a species unlock displays a configurable on-screen title and subtitle naming the species. - [x] 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`. - [x] 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. - [x] 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. - [x] 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. - [x] 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] 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. - [ ] 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 ## Related
@@ -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<ProgressObserver> 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));
}
}
@@ -42,7 +42,9 @@ public final class TreeFellerPlugin extends JavaPlugin {
playerStateRepository, playerStateRepository,
species -> settingsService.current().threshold(species), species -> settingsService.current().threshold(species),
automaticBreakRegistry, automaticBreakRegistry,
progressBossBarObserver, new CompositeProgressObserver(
progressBossBarObserver,
new TreeUnlockAnnouncement(settingsService::current)),
exception -> getLogger().log( exception -> getLogger().log(
Level.SEVERE, "Unable to persist Tree Feller progress", exception)); Level.SEVERE, "Unable to persist Tree Feller progress", exception));
getServer().getPluginManager().registerEvents(progressListener, this); getServer().getPluginManager().registerEvents(progressListener, this);
@@ -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<TreeFellerSettings> settings;
public TreeUnlockAnnouncement(Supplier<TreeFellerSettings> 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');
}
}
@@ -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));
}
}
}