feat(notification): announce earned tree unlocks
This commit is contained in:
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user