feat(notification): announce earned tree unlocks
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
species -> settingsService.current().threshold(species),
|
||||
automaticBreakRegistry,
|
||||
new CompositeProgressObserver(
|
||||
progressBossBarObserver,
|
||||
new TreeUnlockAnnouncement(settingsService::current)),
|
||||
exception -> getLogger().log(
|
||||
Level.SEVERE, "Unable to persist Tree Feller progress", exception));
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user