diff --git a/src/main/java/world/bentobox/chunkblock/ChunkBlock.java b/src/main/java/world/bentobox/chunkblock/ChunkBlock.java index 4346765..4f0f2de 100644 --- a/src/main/java/world/bentobox/chunkblock/ChunkBlock.java +++ b/src/main/java/world/bentobox/chunkblock/ChunkBlock.java @@ -17,6 +17,7 @@ import world.bentobox.chunkblock.chunks.BorderDisplay; import world.bentobox.chunkblock.chunks.ChunkManager; import world.bentobox.chunkblock.listeners.ActivityListener; +import world.bentobox.chunkblock.trophies.TrophyManager; import world.bentobox.chunkblock.commands.admin.AdminCommand; import world.bentobox.chunkblock.commands.island.PlayerCommand; import world.bentobox.chunkblock.dataobjects.OneBlockIslands; @@ -93,6 +94,8 @@ public class ChunkBlock extends GameModeAddon { private ChunkManager chunkManager; /** The per-member activity counters (the ledger/leaderboard/trophy substrate) */ private ActivityManager activityManager; + /** The config-defined island trophies and titles */ + private TrophyManager trophyManager; /** The placeholder manager for ChunkBlock */ private ChunkBlockPlaceholders phManager; /** The listener for hologram-related events */ @@ -249,8 +252,10 @@ public void onEnable() { oneBlockManager = new OneBlocksManager(this); // Initialize the chunk lock manager chunkManager = new ChunkManager(this); - // Initialize the activity counters + // Initialize the activity counters and the trophies that read them activityManager = new ActivityManager(this); + trophyManager = new TrophyManager(this); + trophyManager.loadTrophies(); // Load phase data if (loadData()) { // Failed to load - don't register anything @@ -345,6 +350,9 @@ public void onReload() { log("Reloaded ChunkBlock settings"); loadData(); } + if (trophyManager != null) { + trophyManager.loadTrophies(); + } } /** @@ -368,6 +376,13 @@ public ActivityManager getActivityManager() { return activityManager; } + /** + * @return the trophy and title manager, or null before the addon is enabled + */ + public TrophyManager getTrophyManager() { + return trophyManager; + } + /** * @return the chunk guard listener (containment and backtracking) */ @@ -503,6 +518,8 @@ public void saveDefaultConfig() { super.saveDefaultConfig(); // Save default phases panel this.saveResource("panels/phases_panel.yml", false); + // Save default trophy definitions + this.saveResource("trophies.yml", false); } /* diff --git a/src/main/java/world/bentobox/chunkblock/ChunkBlockPlaceholders.java b/src/main/java/world/bentobox/chunkblock/ChunkBlockPlaceholders.java index 5279212..902c0ec 100644 --- a/src/main/java/world/bentobox/chunkblock/ChunkBlockPlaceholders.java +++ b/src/main/java/world/bentobox/chunkblock/ChunkBlockPlaceholders.java @@ -68,6 +68,33 @@ public ChunkBlockPlaceholders(ChunkBlock addon, placeholdersManager.registerPlaceholder(addon, "island_chunk_credit", this::getIslandChunkCredit); placeholdersManager.registerPlaceholder(addon, "island_ring", this::getIslandRing); placeholdersManager.registerPlaceholder(addon, "island_rings_complete", this::getIslandRingsComplete); + placeholdersManager.registerPlaceholder(addon, "island_title", this::getIslandTitle); + placeholdersManager.registerPlaceholder(addon, "island_trophies", this::getIslandTrophies); + } + + /** + * @param user user + * @return the user's island's active trophy title as configured (MiniMessage text), + * or an empty string for no title + */ + public String getIslandTitle(User user) { + if (user == null || user.getUniqueId() == null || addon.getTrophyManager() == null) { + return ""; + } + return getUsersIsland(user).map(i -> addon.getTrophyManager().getActiveTitleText(i)).orElse(""); + } + + /** + * @param user user + * @return how many trophies the user's island has earned + */ + public String getIslandTrophies(User user) { + if (user == null || user.getUniqueId() == null || addon.getTrophyManager() == null) { + return ""; + } + return getUsersIsland(user) + .map(i -> String.valueOf(addon.getOneBlocksIsland(i).getEarnedTrophies().size())) + .orElse(""); } /** diff --git a/src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java b/src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java index 4fdd2ff..3069152 100644 --- a/src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java +++ b/src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java @@ -1,6 +1,7 @@ package world.bentobox.chunkblock.activity; import java.time.LocalDate; +import java.time.ZoneId; import java.util.HashMap; import java.util.HashSet; import java.util.Map; @@ -43,8 +44,8 @@ public class ActivityManager { /** Unsaved frequent-counter records per island, so block breaks don't save every hit */ private final Map unsavedCounts = new HashMap<>(); - /** Today as an epoch day; replaceable so tests can move time */ - private LongSupplier daySupplier = () -> LocalDate.now().toEpochDay(); + /** Today as an epoch day in the server's zone; replaceable so tests can move time */ + private LongSupplier daySupplier = () -> LocalDate.now(ZoneId.systemDefault()).toEpochDay(); public ActivityManager(ChunkBlock addon) { this.addon = addon; @@ -69,7 +70,7 @@ public void setDaySupplier(LongSupplier daySupplier) { * @param type the counter * @param amount the amount to add, > 0 (anything else is ignored) */ - public void record(@NonNull Island island, @Nullable UUID member, @NonNull CounterType type, long amount) { + public void recordActivity(@NonNull Island island, @Nullable UUID member, @NonNull CounterType type, long amount) { if (amount <= 0 || (member != null && !island.getMemberSet().contains(member))) { return; } @@ -78,6 +79,9 @@ public void record(@NonNull Island island, @Nullable UUID member, @NonNull Count data.add(member == null ? ISLAND_SCOPE : member.toString(), type.name(), today, amount); data.prune(today - Math.max(1, addon.getSettings().getActivityRetentionDays()) + 1); save(data, type == CounterType.MAGIC_BLOCKS); + if (addon.getTrophyManager() != null) { + addon.getTrophyManager().check(island); + } } /** @@ -94,8 +98,8 @@ public void record(@NonNull Island island, @Nullable UUID member, @NonNull Count public boolean recordClaim(@NonNull Island island, int dx, int dz, @Nullable UUID member) { IslandActivity data = getActivity(island.getUniqueId()); boolean first = data.getClaimedEver().add(dx + "," + dz); - record(island, member, first ? CounterType.CHUNKS_CLAIMED : CounterType.CHUNKS_RECLAIMED, 1); - // record() may have skipped saving (non-member) but the claimedEver set changed + recordActivity(island, member, first ? CounterType.CHUNKS_CLAIMED : CounterType.CHUNKS_RECLAIMED, 1); + // recordActivity() may have skipped saving (non-member) but the claimedEver set changed if (first) { save(data, false); } diff --git a/src/main/java/world/bentobox/chunkblock/commands/island/IslandTitleCommand.java b/src/main/java/world/bentobox/chunkblock/commands/island/IslandTitleCommand.java new file mode 100644 index 0000000..7ea83af --- /dev/null +++ b/src/main/java/world/bentobox/chunkblock/commands/island/IslandTitleCommand.java @@ -0,0 +1,121 @@ +package world.bentobox.chunkblock.commands.island; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.util.Util; +import world.bentobox.chunkblock.ChunkBlock; +import world.bentobox.chunkblock.trophies.Trophy; + +/** + * /ch title — shows the island's earned trophies and the titles they carry, and lets a + * member pick which title the island shows. One active title per island, chosen from the + * trophies it has earned; "none" clears it. + * + * @author tastybento + */ +public class IslandTitleCommand extends CompositeCommand { + + private static final String CLEAR = "none"; + private static final String TITLE_VAR = "[title]"; + + private ChunkBlock addon; + + public IslandTitleCommand(CompositeCommand islandCommand, String label, String[] aliases) { + super(islandCommand, label, aliases); + } + + @Override + public void setup() { + setDescription("chunkblock.commands.title.description"); + setParametersHelp("chunkblock.commands.title.parameters"); + setOnlyPlayer(true); + setPermission("island.title"); + addon = getAddon(); + } + + @Override + public boolean canExecute(User user, String label, List args) { + if (!Util.sameWorld(getWorld(), user.getWorld())) { + user.sendMessage("general.errors.wrong-world"); + return false; + } + if (getIslands().getIsland(getWorld(), user) == null) { + user.sendMessage("general.errors.no-island"); + return false; + } + return true; + } + + @Override + public boolean execute(User user, String label, List args) { + Island island = getIslands().getIsland(getWorld(), user); + if (island == null) { + // canExecute already refused this, but never spend credit on a null island + user.sendMessage("general.errors.no-island"); + return false; + } + if (args.isEmpty()) { + showTitles(user, island); + return true; + } + String id = args.get(0); + if (CLEAR.equalsIgnoreCase(id)) { + addon.getTrophyManager().setActiveTitle(island, null); + user.sendMessage("chunkblock.commands.title.cleared"); + return true; + } + if (!addon.getTrophyManager().setActiveTitle(island, id)) { + user.sendMessage("chunkblock.commands.title.not-earned"); + return false; + } + addon.getTrophyManager().getTrophy(id).ifPresent(trophy -> user + .sendMessage("chunkblock.commands.title.set", TITLE_VAR, trophy.title())); + return true; + } + + /** + * Lists the island's earned trophies, marking the ones that carry a title and which + * title is active. + */ + private void showTitles(User user, Island island) { + List earned = addon.getTrophyManager().getEarned(island); + if (earned.isEmpty()) { + user.sendMessage("chunkblock.commands.title.none-earned-yet"); + return; + } + user.sendMessage("chunkblock.commands.title.header"); + for (Trophy trophy : earned) { + if (trophy.title() == null) { + user.sendMessage("chunkblock.commands.title.trophy-entry", "[name]", trophy.name()); + } else { + user.sendMessage("chunkblock.commands.title.title-entry", "[name]", trophy.name(), + TITLE_VAR, trophy.title(), "[id]", trophy.id()); + } + } + String active = addon.getTrophyManager().getActiveTitleText(island); + if (active.isEmpty()) { + user.sendMessage("chunkblock.commands.title.no-active"); + } else { + user.sendMessage("chunkblock.commands.title.active", TITLE_VAR, active); + } + } + + @Override + public Optional> tabComplete(User user, String alias, List args) { + Island island = getIslands().getIsland(getWorld(), user); + if (island == null) { + return Optional.empty(); + } + List options = new ArrayList<>(); + options.add(CLEAR); + addon.getTrophyManager().getEarned(island).stream().filter(t -> t.title() != null) + .map(Trophy::id).forEach(options::add); + String last = args.isEmpty() ? "" : args.get(args.size() - 1); + return Optional.of(Util.tabLimit(options, last)); + } +} diff --git a/src/main/java/world/bentobox/chunkblock/commands/island/PlayerCommand.java b/src/main/java/world/bentobox/chunkblock/commands/island/PlayerCommand.java index 9641d54..b99d6db 100644 --- a/src/main/java/world/bentobox/chunkblock/commands/island/PlayerCommand.java +++ b/src/main/java/world/bentobox/chunkblock/commands/island/PlayerCommand.java @@ -33,6 +33,8 @@ public void setup() { settings.getSetCountCommand().split(" ")); // Chunk territory info and map new IslandChunksCommand(this, "chunks", new String[] {"chunks"}); + // Trophy titles + new IslandTitleCommand(this, "title", new String[] {"title"}); // Force block respawn new IslandRespawnBlockCommand(this, settings.getRespawnBlockCommand().split(" ")[0], diff --git a/src/main/java/world/bentobox/chunkblock/dataobjects/OneBlockIslands.java b/src/main/java/world/bentobox/chunkblock/dataobjects/OneBlockIslands.java index 37369ed..8da52fd 100644 --- a/src/main/java/world/bentobox/chunkblock/dataobjects/OneBlockIslands.java +++ b/src/main/java/world/bentobox/chunkblock/dataobjects/OneBlockIslands.java @@ -77,6 +77,21 @@ public class OneBlockIslands implements DataObject { @Expose private int highestRingRewarded = 0; + /** + * The trophies this island has earned, by trophy id. Earned once, stays earned: + * re-locking and re-claiming never re-awards, and only an island create or reset + * clears this. + */ + @Expose + private Set earnedTrophies = new HashSet<>(); + + /** + * The id of the earned trophy whose title the island currently shows, or empty for + * no title. + */ + @Expose + private String activeTitle = ""; + /** Fast membership view of {@link #unlockedChunks}; rebuilt lazily after loads/edits */ private transient Set unlockedSet; @@ -201,6 +216,39 @@ public void setHighestRingRewarded(int highestRingRewarded) { this.highestRingRewarded = highestRingRewarded; } + /** + * @return the earned trophy ids, never null. Mutable — callers add and clear in place. + */ + @NonNull + public Set getEarnedTrophies() { + if (earnedTrophies == null) { + earnedTrophies = new HashSet<>(); + } + return earnedTrophies; + } + + /** + * @param earnedTrophies the earned trophy ids to set + */ + public void setEarnedTrophies(Set earnedTrophies) { + this.earnedTrophies = earnedTrophies; + } + + /** + * @return the id of the trophy whose title the island shows, or an empty string + */ + @NonNull + public String getActiveTitle() { + return activeTitle == null ? "" : activeTitle; + } + + /** + * @param activeTitle the trophy id whose title to show, or an empty string for none + */ + public void setActiveTitle(String activeTitle) { + this.activeTitle = activeTitle; + } + /** * @return the phaseName */ diff --git a/src/main/java/world/bentobox/chunkblock/events/TrophyAwardEvent.java b/src/main/java/world/bentobox/chunkblock/events/TrophyAwardEvent.java new file mode 100644 index 0000000..0183657 --- /dev/null +++ b/src/main/java/world/bentobox/chunkblock/events/TrophyAwardEvent.java @@ -0,0 +1,76 @@ +package world.bentobox.chunkblock.events; + +import org.bukkit.event.Cancellable; +import org.bukkit.event.HandlerList; +import org.eclipse.jdt.annotation.NonNull; + +import world.bentobox.bentobox.api.events.BentoBoxEvent; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.chunkblock.trophies.Trophy; + +/** + * Fired once when an island earns a trophy. Trophies are earned once and stay earned: the + * trophy is persisted whether or not this event is cancelled, so it can never be awarded + * again — mirroring {@link RingCompleteEvent}. + *

+ * Cancelling suppresses the addon's own award handling (messages, the celebration sound + * and the configured reward commands), so another plugin can take the reward over + * entirely without the addon also acting. + * + * @author tastybento + */ +public class TrophyAwardEvent extends BentoBoxEvent implements Cancellable { + + private static final HandlerList handlers = new HandlerList(); + + private final Island island; + private final Trophy trophy; + private boolean cancelled; + + /** + * @param island the island that earned the trophy + * @param trophy the trophy earned + */ + public TrophyAwardEvent(@NonNull Island island, @NonNull Trophy trophy) { + this.island = island; + this.trophy = trophy; + } + + @Override + public HandlerList getHandlers() { + return getHandlerList(); + } + + // Bukkit's event bus looks this method up reflectively by this exact name on every + // event class, so it must shadow the superclass method + @SuppressWarnings("java:S9149") + public static HandlerList getHandlerList() { + return handlers; + } + + /** + * @return the island that earned the trophy + */ + @NonNull + public Island getIsland() { + return island; + } + + /** + * @return the trophy earned + */ + @NonNull + public Trophy getTrophy() { + return trophy; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } +} diff --git a/src/main/java/world/bentobox/chunkblock/listeners/ActivityListener.java b/src/main/java/world/bentobox/chunkblock/listeners/ActivityListener.java index 9b47669..2e1900a 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/ActivityListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/ActivityListener.java @@ -38,7 +38,7 @@ public ActivityListener(ChunkBlock addon) { @EventHandler(priority = EventPriority.MONITOR) public void onMagicBlock(MagicBlockEvent e) { if (addon.inWorld(e.getIsland().getWorld())) { - addon.getActivityManager().record(e.getIsland(), e.getPlayerUUID(), CounterType.MAGIC_BLOCKS, 1); + addon.getActivityManager().recordActivity(e.getIsland(), e.getPlayerUUID(), CounterType.MAGIC_BLOCKS, 1); } } @@ -60,7 +60,7 @@ public void onChunkUnlock(ChunkUnlockEvent e) { @EventHandler(priority = EventPriority.MONITOR) public void onChunkRelock(ChunkRelockEvent e) { if (addon.inWorld(e.getIsland().getWorld())) { - addon.getActivityManager().record(e.getIsland(), null, CounterType.CHUNKS_RELOCKED, 1); + addon.getActivityManager().recordActivity(e.getIsland(), null, CounterType.CHUNKS_RELOCKED, 1); } } @@ -72,7 +72,7 @@ public void onChunkRelock(ChunkRelockEvent e) { @EventHandler(priority = EventPriority.MONITOR) public void onRingComplete(RingCompleteEvent e) { if (addon.inWorld(e.getIsland().getWorld())) { - addon.getActivityManager().record(e.getIsland(), null, CounterType.RINGS_COMPLETED, 1); + addon.getActivityManager().recordActivity(e.getIsland(), null, CounterType.RINGS_COMPLETED, 1); } } diff --git a/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java b/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java index def1933..a76f81b 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java @@ -81,6 +81,8 @@ private void resetIsland(Island island) { data.resetUnlockedChunks(); data.setLastKnownLevel(0); data.setHighestRingRewarded(0); + data.getEarnedTrophies().clear(); + data.setActiveTitle(""); } /** @@ -99,7 +101,7 @@ public void applyLevel(Island island, long level) { // The delta is only known here, so level gains are recorded directly rather than // through ActivityListener. Island scope: Level cannot attribute levels to a member. if (level > oldLevel && addon.getActivityManager() != null) { - addon.getActivityManager().record(island, null, CounterType.LEVELS_EARNED, level - oldLevel); + addon.getActivityManager().recordActivity(island, null, CounterType.LEVELS_EARNED, level - oldLevel); } // Level dropped below what has been spent → the most recent claims are lost if (addon.getSettings().isRelockOnLevelLoss() && cm.getSpentLevels(island) > Math.max(0, level)) { diff --git a/src/main/java/world/bentobox/chunkblock/trophies/Trophy.java b/src/main/java/world/bentobox/chunkblock/trophies/Trophy.java new file mode 100644 index 0000000..f0c66f8 --- /dev/null +++ b/src/main/java/world/bentobox/chunkblock/trophies/Trophy.java @@ -0,0 +1,61 @@ +package world.bentobox.chunkblock.trophies; + +import java.util.List; + +import org.bukkit.Material; +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; + +import world.bentobox.chunkblock.activity.CounterType; + +/** + * One config-defined trophy: a name, an icon, a condition, and what earning it grants. + * Trophies are earned once and stay earned — re-locking and re-claiming never re-awards — + * and are cleared only by an island create or reset. + * + * @param id the trophy's config key, unique among trophies + * @param name display name, MiniMessage text + * @param description one-line description, MiniMessage text + * @param icon icon material for panels and dialogs + * @param title the island title this trophy makes available, MiniMessage text, or null + * if the trophy carries no title + * @param criteria when the trophy is earned + * @param commands console commands run once on award; placeholders [owner] and [trophy] + * @param playerCommands console commands run once per island member on award; + * placeholders [player] and [trophy] + * @author tastybento + */ +public record Trophy(@NonNull String id, @NonNull String name, @NonNull String description, + @NonNull Material icon, @Nullable String title, @NonNull Criteria criteria, + @NonNull List commands, @NonNull List playerCommands) { + + /** What kind of condition earns the trophy */ + public enum CriteriaType { + /** Earned when the island has completed the given ring */ + RING, + /** Earned when an activity counter reaches a threshold */ + COUNTER + } + + /** Whose counter a COUNTER criteria reads */ + public enum Scope { + /** The island's total across all members */ + ISLAND, + /** Any single member's own count */ + MEMBER + } + + /** + * A trophy's earning condition. + * + * @param type the condition kind + * @param ring for RING: the ring that must be complete, >= 1 + * @param counter for COUNTER: the counter read + * @param scope for COUNTER: island total or any single member + * @param threshold for COUNTER: the amount that must be reached, >= 1 + * @param windowDays for COUNTER: how many days back to count, 0 for lifetime + */ + public record Criteria(@NonNull CriteriaType type, int ring, @Nullable CounterType counter, + @NonNull Scope scope, long threshold, int windowDays) { + } +} diff --git a/src/main/java/world/bentobox/chunkblock/trophies/TrophyManager.java b/src/main/java/world/bentobox/chunkblock/trophies/TrophyManager.java new file mode 100644 index 0000000..fc9cf02 --- /dev/null +++ b/src/main/java/world/bentobox/chunkblock/trophies/TrophyManager.java @@ -0,0 +1,332 @@ +package world.bentobox.chunkblock.trophies; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.chunkblock.ChunkBlock; +import world.bentobox.chunkblock.activity.CounterType; +import world.bentobox.chunkblock.dataobjects.OneBlockIslands; +import world.bentobox.chunkblock.events.TrophyAwardEvent; +import world.bentobox.chunkblock.trophies.Trophy.Criteria; +import world.bentobox.chunkblock.trophies.Trophy.CriteriaType; +import world.bentobox.chunkblock.trophies.Trophy.Scope; + +/** + * The reward layer over the activity counters: config-defined trophies an island earns + * once and keeps, and the titles they carry. Conditions read the counters and the ring + * state; nothing here is hardcoded to a particular milestone. + *

+ * Earned trophies persist on the island and only an island create or reset clears them, + * so re-locking a ring on level loss and claiming it back never re-awards — the same + * lesson as {@code highestRingRewarded}. + * + * @author tastybento + */ +public class TrophyManager { + + private static final String TROPHIES_FILE = "trophies.yml"; + /** Prefix for config-validation complaints */ + private static final String TROPHY = "Trophy '"; + + private final ChunkBlock addon; + /** Trophies by id, in config order */ + private Map trophies = new LinkedHashMap<>(); + /** Islands currently mid-check, so an award's own side effects cannot re-enter */ + private final Set checking = new HashSet<>(); + + public TrophyManager(ChunkBlock addon) { + this.addon = addon; + } + + /** + * Loads trophy definitions from trophies.yml in the addon's data folder. Invalid + * trophies are logged and skipped; the rest still load. + */ + public void loadTrophies() { + File file = new File(addon.getDataFolder(), TROPHIES_FILE); + if (!file.exists()) { + addon.saveResource(TROPHIES_FILE, false); + } + loadTrophies(YamlConfiguration.loadConfiguration(file).getConfigurationSection("trophies")); + } + + /** + * Loads trophy definitions from a configuration section. Exposed for testing. + * + * @param section the "trophies" section, each key one trophy; null loads nothing + */ + public void loadTrophies(@Nullable ConfigurationSection section) { + Map loaded = new LinkedHashMap<>(); + if (section != null) { + for (String id : section.getKeys(false)) { + ConfigurationSection ts = section.getConfigurationSection(id); + if (ts == null) { + addon.logError(TROPHY + id + "' is not a section - skipping."); + continue; + } + Trophy trophy = parseTrophy(id, ts); + if (trophy != null) { + loaded.put(id, trophy); + } + } + } + trophies = loaded; + } + + @Nullable + private Trophy parseTrophy(String id, ConfigurationSection ts) { + Material icon = Material.matchMaterial(ts.getString("icon", "GOLD_INGOT")); + if (icon == null) { + addon.logError(TROPHY + id + "': unknown icon material '" + ts.getString("icon") + + "' - skipping."); + return null; + } + Criteria criteria = parseCriteria(id, ts.getConfigurationSection("criteria")); + if (criteria == null) { + return null; + } + return new Trophy(id, ts.getString("name", id), ts.getString("description", ""), icon, + ts.getString("title"), criteria, ts.getStringList("rewards.commands"), + ts.getStringList("rewards.player-commands")); + } + + @Nullable + private Criteria parseCriteria(String id, @Nullable ConfigurationSection cs) { + if (cs == null) { + addon.logError(TROPHY + id + "': no criteria section - skipping."); + return null; + } + CriteriaType type = matchEnum(CriteriaType.class, cs.getString("type")); + if (type == null) { + addon.logError(TROPHY + id + "': criteria type must be RING or COUNTER - skipping."); + return null; + } + if (type == CriteriaType.RING) { + int ring = cs.getInt("ring", 0); + if (ring < 1) { + addon.logError(TROPHY + id + "': ring must be 1 or more - skipping."); + return null; + } + return new Criteria(type, ring, null, Scope.ISLAND, 0, 0); + } + CounterType counter = matchEnum(CounterType.class, cs.getString("counter")); + if (counter == null) { + addon.logError(TROPHY + id + "': unknown counter '" + cs.getString("counter") + + "' - skipping."); + return null; + } + long threshold = cs.getLong("threshold", 0); + if (threshold < 1) { + addon.logError(TROPHY + id + "': threshold must be 1 or more - skipping."); + return null; + } + Scope scope = matchEnum(Scope.class, cs.getString("scope", "ISLAND")); + if (scope == null) { + addon.logError(TROPHY + id + "': scope must be ISLAND or MEMBER - skipping."); + return null; + } + return new Criteria(type, 0, counter, scope, threshold, Math.max(0, cs.getInt("window-days", 0))); + } + + @Nullable + private > T matchEnum(Class clazz, @Nullable String name) { + if (name == null) { + return null; + } + try { + return Enum.valueOf(clazz, name.toUpperCase(Locale.ENGLISH).replace('-', '_')); + } catch (IllegalArgumentException e) { + return null; + } + } + + /** + * @return all loaded trophies in config order + */ + @NonNull + public Collection getTrophies() { + return Collections.unmodifiableCollection(trophies.values()); + } + + /** + * @param id trophy id + * @return the trophy, or empty if no such trophy is defined + */ + @NonNull + public Optional getTrophy(@Nullable String id) { + return id == null ? Optional.empty() : Optional.ofNullable(trophies.get(id)); + } + + /** + * Evaluates every trophy this island has not yet earned and awards the ones whose + * condition is now met. Called whenever activity is recorded; cheap unless something + * is actually awarded. + * + * @param island the island to check + */ + public void check(@NonNull Island island) { + if (trophies.isEmpty() || !checking.add(island.getUniqueId())) { + // Re-entered from an award's own side effects — the outer check finishes the job + return; + } + try { + OneBlockIslands data = addon.getOneBlocksIsland(island); + for (Trophy trophy : trophies.values()) { + if (!data.getEarnedTrophies().contains(trophy.id()) && isMet(island, trophy)) { + award(island, data, trophy); + } + } + } finally { + checking.remove(island.getUniqueId()); + } + } + + private boolean isMet(Island island, Trophy trophy) { + Criteria c = trophy.criteria(); + if (c.type() == CriteriaType.RING) { + return addon.getChunkManager().completedRings(island) >= c.ring(); + } + // Parsing guarantees every COUNTER criteria has a counter + CounterType counter = Objects.requireNonNull(c.counter()); + if (c.scope() == Scope.ISLAND) { + return addon.getActivityManager().getCount(island, null, counter, c.windowDays()) >= c + .threshold(); + } + return addon.getActivityManager().getContributors(island).stream().anyMatch( + uuid -> addon.getActivityManager().getCount(island, uuid, counter, c.windowDays()) >= c + .threshold()); + } + + /** + * Awards a trophy. The trophy is persisted as earned before the event goes out, so it + * can never be awarded twice; cancelling the event suppresses only the addon's own + * handling — messages, sound, title and reward commands. + */ + private void award(Island island, OneBlockIslands data, Trophy trophy) { + data.getEarnedTrophies().add(trophy.id()); + // First titled trophy becomes the island's title until someone picks another + if (trophy.title() != null && data.getActiveTitle().isEmpty()) { + data.setActiveTitle(trophy.id()); + } + addon.getBlockListener().saveIsland(island); + TrophyAwardEvent event = new TrophyAwardEvent(island, trophy); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + return; + } + island.getMemberSet().forEach(uuid -> { + User user = User.getInstance(uuid); + if (user.isOnline() && addon.inWorld(user.getWorld())) { + user.sendMessage("chunkblock.trophies.awarded", "[name]", trophy.name()); + if (trophy.title() != null) { + user.sendMessage("chunkblock.trophies.title-available", "[title]", trophy.title()); + } + user.getPlayer().playSound(user.getLocation(), Sound.UI_TOAST_CHALLENGE_COMPLETE, 1F, 1.2F); + } + }); + runCommands(island, trophy); + } + + /** + * Runs the trophy's console reward commands. The config warns against paying out + * island levels here: levels buy chunks, so a level-granting trophy makes each + * milestone buy the next one. + */ + private void runCommands(Island island, Trophy trophy) { + String ownerName = island.getOwner() == null ? "" : addon.getPlayers().getName(island.getOwner()); + for (String command : trophy.commands()) { + if (!ownerName.isEmpty()) { + dispatch(command.replace("[owner]", ownerName).replace("[trophy]", trophy.id())); + } + } + if (!trophy.playerCommands().isEmpty()) { + for (UUID uuid : island.getMemberSet()) { + String name = addon.getPlayers().getName(uuid); + if (name != null && !name.isEmpty()) { + for (String command : trophy.playerCommands()) { + dispatch(command.replace("[player]", name).replace("[trophy]", trophy.id())); + } + } + } + } + } + + private void dispatch(String command) { + if (!Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command)) { + addon.logError("Trophy reward command failed: " + command); + } + } + + /** + * @param island the island + * @return the trophies this island has earned, in config order + */ + @NonNull + public List getEarned(@NonNull Island island) { + Set earned = addon.getOneBlocksIsland(island).getEarnedTrophies(); + List result = new ArrayList<>(); + for (Trophy trophy : trophies.values()) { + if (earned.contains(trophy.id())) { + result.add(trophy); + } + } + return result; + } + + /** + * @param island the island + * @return the island's active title text (MiniMessage), or an empty string if the + * island has no title or its trophy is no longer defined + */ + @NonNull + public String getActiveTitleText(@NonNull Island island) { + return getTrophy(addon.getOneBlocksIsland(island).getActiveTitle()) + .map(trophy -> trophy.title() == null ? "" : trophy.title()).orElse(""); + } + + /** + * Sets the island's active title to the one carried by an earned trophy, or clears it. + * + * @param island the island + * @param trophyId an earned trophy's id whose title to show, or null to clear + * @return true if the title was set or cleared, false if the trophy is unknown, + * unearned or carries no title + */ + public boolean setActiveTitle(@NonNull Island island, @Nullable String trophyId) { + OneBlockIslands data = addon.getOneBlocksIsland(island); + if (trophyId == null) { + data.setActiveTitle(""); + addon.getBlockListener().saveIsland(island); + return true; + } + Optional trophy = getTrophy(trophyId); + if (trophy.isEmpty() || trophy.get().title() == null + || !data.getEarnedTrophies().contains(trophyId)) { + return false; + } + data.setActiveTitle(trophyId); + addon.getBlockListener().saveIsland(island); + return true; + } +} diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml index 1904e32..375f4d1 100755 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -331,6 +331,9 @@ permissions: chunkblock.island.chunks: description: Allow use of '/ch chunks' command - show your unlocked chunks and territory map default: TRUE + chunkblock.island.title: + description: Allow use of '/ch title' command - show earned trophies and choose the island's title + default: TRUE chunkblock.admin.chunks: description: Allow use of '/chadmin chunks' command - inspect, set or recalculate a player's unlocked chunks default: OP diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index f16f8c9..bb8d7b0 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -85,9 +85,24 @@ chunkblock: actionbar: status: "Phase: [phase-name] | Blocks: [done] / [total] | Progression: [percent-done]" not-active: "Action Bar is not active for this island" + trophies: + awarded: "Trophy earned: [name]!" + title-available: "Your island can now show the title [title] — pick it with /ch title." commands: chunks: description: "show your unlocked chunks and a map of your territory" + title: + description: "show your island's trophies and choose its title" + parameters: "[trophy-id | none]" + header: "Your island's trophies:" + trophy-entry: " - [name]" + title-entry: " - [name] — title [title] (/ch title [id])" + none-earned-yet: "Your island has not earned any trophies yet." + active: "Active title: [title]" + no-active: "No title is active. Pick one with /ch title." + set: "Your island's title is now [title]." + cleared: "Your island no longer shows a title." + not-earned: "Your island has not earned a trophy with that title." admin: chunks: parameters: " [reset]" diff --git a/src/main/resources/trophies.yml b/src/main/resources/trophies.yml new file mode 100644 index 0000000..1df9d47 --- /dev/null +++ b/src/main/resources/trophies.yml @@ -0,0 +1,64 @@ +# ChunkBlock trophies. +# +# A trophy is a name, an icon, a condition, and what earning it grants. An island earns +# each trophy once and keeps it: re-locking chunks on level loss and claiming them back +# never re-awards, and only an island create or reset clears earned trophies. +# +# A trophy with a "title" makes that title available to the island. The island's active +# title is chosen with /ch title, shown by the %chunkblock_island_title% placeholder. +# +# ------------------------------------------------------------------------------------ +# DO NOT reward island levels from trophy commands. Levels are the currency that buys +# chunks in ChunkBlock, so a trophy that grants levels makes each milestone pay for the +# next one — a compounding loop, not a reward. Economy money, items, cosmetics and +# titles are all fine; levels are not. +# ------------------------------------------------------------------------------------ +# +# Format, all fields under a unique trophy id: +# name: display name (MiniMessage text, e.g. "First Ring") +# description: one line shown in listings (MiniMessage) +# icon: a Bukkit Material name for panels/dialogs +# title: OPTIONAL island title this trophy carries (MiniMessage) +# criteria: +# type: RING or COUNTER +# # for RING: +# ring: 1 # earned when this ring of chunks is complete (1 = the eight +# # chunks around the center) +# # for COUNTER: +# counter: one of MAGIC_BLOCKS, LEVELS_EARNED, CHUNKS_CLAIMED, CHUNKS_RECLAIMED, +# CHUNKS_RELOCKED, RINGS_COMPLETED +# scope: ISLAND # ISLAND = the island's total, MEMBER = any single member +# threshold: 10 # the amount that must be reached +# window-days: 0 # 0 = lifetime; otherwise only the last N days count (must be +# # within chunkblock.activity.daily-retention-days in config.yml) +# rewards: +# commands: [] # console commands run once. Placeholders: [owner], [trophy] +# player-commands: [] # console commands run once per member. Placeholders: [player], [trophy] +trophies: + first-ring: + name: "First Ring" + description: "Complete the first ring of chunks around your magic block." + icon: GOLD_INGOT + title: "Ring Bearer" + criteria: + type: RING + ring: 1 + homesteader: + name: "Homesteader" + description: "Claim ten different chunks for your island." + icon: GRASS_BLOCK + criteria: + type: COUNTER + counter: CHUNKS_CLAIMED + scope: ISLAND + threshold: 10 + ten-thousand-blocks: + name: "Ten Thousand Blocks" + description: "Break ten thousand magic blocks as an island." + icon: DIAMOND_PICKAXE + title: "Block Breaker" + criteria: + type: COUNTER + counter: MAGIC_BLOCKS + scope: ISLAND + threshold: 10000 diff --git a/src/test/java/world/bentobox/chunkblock/ChunkBlockTest.java b/src/test/java/world/bentobox/chunkblock/ChunkBlockTest.java index 73c8bf4..fd3649b 100644 --- a/src/test/java/world/bentobox/chunkblock/ChunkBlockTest.java +++ b/src/test/java/world/bentobox/chunkblock/ChunkBlockTest.java @@ -70,6 +70,7 @@ public void tearDown() throws Exception { deleteAll(new File("database")); deleteAll(new File("database_backup")); new File("config.yml").delete(); + new File("trophies.yml").delete(); deleteAll(new File("addons")); deleteAll(new File("panels")); } @@ -134,6 +135,14 @@ public void setUp() throws Exception { // Add the new files to the jar. add(path, tempJarOutputStream); + + // Copy over trophies file from src folder + fromPath = Paths.get("src/main/resources/trophies.yml"); + path = Paths.get("trophies.yml"); + Files.copy(fromPath, path); + + // Add the new files to the jar. + add(path, tempJarOutputStream); } File dataFolder = new File("addons/ChunkBlock"); diff --git a/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java b/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java index 9ad5ff8..a6d6a1e 100644 --- a/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java +++ b/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -79,7 +80,7 @@ public void tearDown() throws Exception { @Test void testRecordAndLifetime() { - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 5); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 5); assertEquals(5, am.getCount(island, uuid, CounterType.MAGIC_BLOCKS, 0)); // Island total includes the member's contribution assertEquals(5, am.getCount(island, null, CounterType.MAGIC_BLOCKS, 0)); @@ -90,21 +91,21 @@ void testRecordAndLifetime() { @Test void testNonMemberIsNotCounted() { UUID stranger = UUID.randomUUID(); - am.record(island, stranger, CounterType.MAGIC_BLOCKS, 5); + am.recordActivity(island, stranger, CounterType.MAGIC_BLOCKS, 5); assertEquals(0, am.getCount(island, stranger, CounterType.MAGIC_BLOCKS, 0)); assertEquals(0, am.getCount(island, null, CounterType.MAGIC_BLOCKS, 0)); } @Test void testNonPositiveAmountIgnored() { - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 0); - am.record(island, uuid, CounterType.MAGIC_BLOCKS, -3); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 0); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, -3); assertEquals(0, am.getCount(island, uuid, CounterType.MAGIC_BLOCKS, 0)); } @Test void testIslandScopeRecording() { - am.record(island, null, CounterType.LEVELS_EARNED, 10); + am.recordActivity(island, null, CounterType.LEVELS_EARNED, 10); assertEquals(10, am.getCount(island, null, CounterType.LEVELS_EARNED, 0)); // Nothing was attributed to the member assertEquals(0, am.getCount(island, uuid, CounterType.LEVELS_EARNED, 0)); @@ -112,9 +113,9 @@ void testIslandScopeRecording() { @Test void testTimeWindows() { - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 3); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 3); day += 5; - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 2); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 2); // Today only assertEquals(2, am.getCount(island, uuid, CounterType.MAGIC_BLOCKS, 1)); // Last 3 days miss the older record @@ -130,10 +131,10 @@ void testTimeWindows() { @Test void testRetentionPrunesDailyButNotLifetime() { settings.setActivityRetentionDays(7); - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 3); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 3); day += 10; // Recording again prunes the now-too-old bucket - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 1); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 1); assertEquals(1, am.getCount(island, uuid, CounterType.MAGIC_BLOCKS, 30)); // Lifetime is never pruned assertEquals(4, am.getCount(island, uuid, CounterType.MAGIC_BLOCKS, 0)); @@ -157,8 +158,8 @@ void testRecordClaimDistinguishesFirstClaimFromRecovery() { void testMembersKeepSeparateCounts() { UUID mate = UUID.randomUUID(); when(island.getMemberSet()).thenReturn(ImmutableSet.of(uuid, mate)); - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 3); - am.record(island, mate, CounterType.MAGIC_BLOCKS, 4); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 3); + am.recordActivity(island, mate, CounterType.MAGIC_BLOCKS, 4); assertEquals(3, am.getCount(island, uuid, CounterType.MAGIC_BLOCKS, 0)); assertEquals(4, am.getCount(island, mate, CounterType.MAGIC_BLOCKS, 0)); assertEquals(7, am.getCount(island, null, CounterType.MAGIC_BLOCKS, 0)); @@ -167,13 +168,13 @@ void testMembersKeepSeparateCounts() { @Test void testContributorsExcludeIslandScope() { - am.record(island, null, CounterType.LEVELS_EARNED, 10); + am.recordActivity(island, null, CounterType.LEVELS_EARNED, 10); assertTrue(am.getContributors(island).isEmpty()); } @Test void testResetIslandClearsEverything() { - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 5); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 5); assertTrue(am.recordClaim(island, 1, 0, uuid)); am.resetIsland("island-id"); assertEquals(0, am.getCount(island, uuid, CounterType.MAGIC_BLOCKS, 0)); @@ -184,26 +185,38 @@ void testResetIslandClearsEverything() { @Test void testFrequentCounterSavesAreThrottled() throws Exception { for (int i = 0; i < 19; i++) { - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 1); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 1); } verify(h, never()).saveObject(any()); - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 1); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 1); verify(h).saveObject(any()); } @Test void testRareCountersSaveImmediately() throws Exception { - am.record(island, uuid, CounterType.CHUNKS_CLAIMED, 1); + am.recordActivity(island, uuid, CounterType.CHUNKS_CLAIMED, 1); verify(h).saveObject(any()); } @Test void testSaveCacheNowWritesDirectly() throws Exception { - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 1); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 1); am.saveCacheNow(); verify(h).saveObjectNow(any()); } + @Test + void testTrophyCheckRunsOnRecord() { + world.bentobox.chunkblock.trophies.TrophyManager tm = mock( + world.bentobox.chunkblock.trophies.TrophyManager.class); + when(addon.getTrophyManager()).thenReturn(tm); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 1); + verify(tm).check(island); + // A dropped record still checks nothing new but must not blow up + am.recordActivity(island, UUID.randomUUID(), CounterType.MAGIC_BLOCKS, 1); + verify(tm, times(1)).check(island); + } + @Test void testDataSurvivesSerializationShape() { // The flat map shape is the storage contract: composite keys, plain longs diff --git a/src/test/java/world/bentobox/chunkblock/commands/island/IslandTitleCommandTest.java b/src/test/java/world/bentobox/chunkblock/commands/island/IslandTitleCommandTest.java new file mode 100644 index 0000000..e0652fd --- /dev/null +++ b/src/test/java/world/bentobox/chunkblock/commands/island/IslandTitleCommandTest.java @@ -0,0 +1,139 @@ +package world.bentobox.chunkblock.commands.island; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; + +import org.bukkit.Material; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.chunkblock.ChunkBlock; +import world.bentobox.chunkblock.CommonTestSetup; +import world.bentobox.chunkblock.trophies.Trophy; +import world.bentobox.chunkblock.trophies.Trophy.Criteria; +import world.bentobox.chunkblock.trophies.Trophy.CriteriaType; +import world.bentobox.chunkblock.trophies.Trophy.Scope; +import world.bentobox.chunkblock.trophies.TrophyManager; + +/** + * Tests /ch title: listing earned trophies and choosing or clearing the island's title. + */ +class IslandTitleCommandTest extends CommonTestSetup { + + private static final Trophy TITLED = new Trophy("first-ring", "First Ring", "", + Material.GOLD_INGOT, "Ring Bearer", + new Criteria(CriteriaType.RING, 1, null, Scope.ISLAND, 0, 0), List.of(), List.of()); + private static final Trophy UNTITLED = new Trophy("homesteader", "Homesteader", "", + Material.GRASS_BLOCK, null, + new Criteria(CriteriaType.RING, 2, null, Scope.ISLAND, 0, 0), List.of(), List.of()); + + @Mock + private CompositeCommand ac; + @Mock + private User user; + @Mock + private ChunkBlock addon; + @Mock + private TrophyManager tm; + + private IslandTitleCommand command; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + when(ac.getAddon()).thenReturn(addon); + when(ac.getWorld()).thenReturn(world); + when(world.getName()).thenReturn("chunkblock_world"); + when(addon.getTrophyManager()).thenReturn(tm); + when(im.getIsland(any(), any(User.class))).thenReturn(island); + command = new IslandTitleCommand(ac, "title", new String[] { "title" }); + } + + @Test + void testSetup() { + assertEquals("island.title", command.getPermission()); + assertEquals("chunkblock.commands.title.description", command.getDescription()); + assertTrue(command.isOnlyPlayer()); + } + + @Test + void testListWithNothingEarned() { + when(tm.getEarned(island)).thenReturn(List.of()); + assertTrue(command.execute(user, "title", List.of())); + verify(user).sendMessage("chunkblock.commands.title.none-earned-yet"); + } + + @Test + void testListShowsTrophiesTitlesAndActiveTitle() { + when(tm.getEarned(island)).thenReturn(List.of(TITLED, UNTITLED)); + when(tm.getActiveTitleText(island)).thenReturn("Ring Bearer"); + assertTrue(command.execute(user, "title", List.of())); + verify(user).sendMessage("chunkblock.commands.title.header"); + verify(user).sendMessage("chunkblock.commands.title.title-entry", "[name]", "First Ring", + "[title]", "Ring Bearer", "[id]", "first-ring"); + verify(user).sendMessage("chunkblock.commands.title.trophy-entry", "[name]", "Homesteader"); + verify(user).sendMessage("chunkblock.commands.title.active", "[title]", "Ring Bearer"); + } + + @Test + void testListMentionsWhenNoTitleIsActive() { + when(tm.getEarned(island)).thenReturn(List.of(UNTITLED)); + when(tm.getActiveTitleText(island)).thenReturn(""); + assertTrue(command.execute(user, "title", List.of())); + verify(user).sendMessage("chunkblock.commands.title.no-active"); + } + + @Test + void testSetTitle() { + when(tm.setActiveTitle(island, "first-ring")).thenReturn(true); + when(tm.getTrophy("first-ring")).thenReturn(Optional.of(TITLED)); + assertTrue(command.execute(user, "title", List.of("first-ring"))); + verify(user).sendMessage("chunkblock.commands.title.set", "[title]", "Ring Bearer"); + } + + @Test + void testSetUnearnedTitleFails() { + when(tm.setActiveTitle(island, "first-ring")).thenReturn(false); + assertFalse(command.execute(user, "title", List.of("first-ring"))); + verify(user).sendMessage("chunkblock.commands.title.not-earned"); + } + + @Test + void testClearTitle() { + assertTrue(command.execute(user, "title", List.of("none"))); + verify(tm).setActiveTitle(island, null); + verify(user).sendMessage("chunkblock.commands.title.cleared"); + } + + @Test + void testTabCompleteOffersNoneAndTitledTrophies() { + when(tm.getEarned(island)).thenReturn(List.of(TITLED, UNTITLED)); + Optional> options = command.tabComplete(user, "title", List.of("")); + assertTrue(options.isPresent()); + assertTrue(options.get().contains("none")); + assertTrue(options.get().contains("first-ring")); + // A trophy with no title is not offered + assertFalse(options.get().contains("homesteader")); + } + + @Test + void testNoIslandCannotExecute() { + when(im.getIsland(any(), any(User.class))).thenReturn(null); + when(user.getWorld()).thenReturn(world); + assertFalse(command.canExecute(user, "title", List.of())); + verify(user).sendMessage("general.errors.no-island"); + verify(tm, never()).setActiveTitle(any(), any()); + } +} diff --git a/src/test/java/world/bentobox/chunkblock/listeners/ActivityListenerTest.java b/src/test/java/world/bentobox/chunkblock/listeners/ActivityListenerTest.java index 0aae28d..b0a00c6 100644 --- a/src/test/java/world/bentobox/chunkblock/listeners/ActivityListenerTest.java +++ b/src/test/java/world/bentobox/chunkblock/listeners/ActivityListenerTest.java @@ -1,7 +1,9 @@ package world.bentobox.chunkblock.listeners; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -55,13 +57,13 @@ public void setUp() throws Exception { @Test void testMagicBlockBreakByPlayer() { listener.onMagicBlock(new MagicBlockEvent(island, uuid, null, block, null)); - verify(am).record(island, uuid, CounterType.MAGIC_BLOCKS, 1); + verify(am).recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 1); } @Test void testMagicBlockBreakByMinionIsIslandScope() { listener.onMagicBlock(new MagicBlockEvent(island, null, null, block, null)); - verify(am).record(island, null, CounterType.MAGIC_BLOCKS, 1); + verify(am).recordActivity(island, null, CounterType.MAGIC_BLOCKS, 1); } @Test @@ -73,7 +75,7 @@ void testChunkUnlockRecordsClaimWithClaimer() { @Test void testChunkRelockIsIslandScope() { listener.onChunkRelock(new ChunkRelockEvent(island, new Vector(1, 0, 0), 3)); - verify(am).record(island, null, CounterType.CHUNKS_RELOCKED, 1); + verify(am).recordActivity(island, null, CounterType.CHUNKS_RELOCKED, 1); } @Test @@ -81,7 +83,7 @@ void testRingCompleteCountsEvenWhenCancelled() { RingCompleteEvent event = new RingCompleteEvent(island, 1, 9); event.setCancelled(true); listener.onRingComplete(event); - verify(am).record(island, null, CounterType.RINGS_COMPLETED, 1); + verify(am).recordActivity(island, null, CounterType.RINGS_COMPLETED, 1); } @Test @@ -129,6 +131,6 @@ void testNullWorldNeverRecords() { when(island.getWorld()).thenReturn(null); when(addon.inWorld((org.bukkit.World) null)).thenReturn(false); listener.onMagicBlock(new MagicBlockEvent(island, uuid, null, block, null)); - verify(am, org.mockito.Mockito.never()).record(any(), any(), any(), org.mockito.ArgumentMatchers.anyLong()); + verify(am, never()).recordActivity(any(), any(), any(), anyLong()); } } diff --git a/src/test/java/world/bentobox/chunkblock/requests/MemberActivityHandlerTest.java b/src/test/java/world/bentobox/chunkblock/requests/MemberActivityHandlerTest.java index 78340aa..13cba8b 100644 --- a/src/test/java/world/bentobox/chunkblock/requests/MemberActivityHandlerTest.java +++ b/src/test/java/world/bentobox/chunkblock/requests/MemberActivityHandlerTest.java @@ -6,7 +6,6 @@ import java.util.Map; import java.util.Optional; -import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/src/test/java/world/bentobox/chunkblock/trophies/TrophyManagerTest.java b/src/test/java/world/bentobox/chunkblock/trophies/TrophyManagerTest.java new file mode 100644 index 0000000..5cb2c73 --- /dev/null +++ b/src/test/java/world/bentobox/chunkblock/trophies/TrophyManagerTest.java @@ -0,0 +1,261 @@ +package world.bentobox.chunkblock.trophies; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +import world.bentobox.bentobox.managers.PlayersManager; +import world.bentobox.chunkblock.ChunkBlock; +import world.bentobox.chunkblock.CommonTestSetup; +import world.bentobox.chunkblock.activity.ActivityManager; +import world.bentobox.chunkblock.activity.CounterType; +import world.bentobox.chunkblock.chunks.ChunkManager; +import world.bentobox.chunkblock.dataobjects.OneBlockIslands; +import world.bentobox.chunkblock.events.TrophyAwardEvent; +import world.bentobox.chunkblock.listeners.BlockListener; + +/** + * The spec for trophies: config parsing, criteria over rings and counters, the + * earned-once guarantee, the cancellable award event, and titles. + * + * @author tastybento + */ +class TrophyManagerTest extends CommonTestSetup { + + private static final String TROPHY_YAML = """ + trophies: + first-ring: + name: "First Ring" + description: "Complete ring 1" + icon: GOLD_INGOT + title: "Ring Bearer" + criteria: + type: RING + ring: 1 + rewards: + commands: + - "eco give [owner] 100" + homesteader: + name: "Homesteader" + icon: GRASS_BLOCK + criteria: + type: COUNTER + counter: CHUNKS_CLAIMED + scope: ISLAND + threshold: 10 + breaker: + name: "Breaker" + icon: DIAMOND_PICKAXE + criteria: + type: COUNTER + counter: MAGIC_BLOCKS + scope: MEMBER + threshold: 100 + window-days: 7 + bad-icon: + name: "Bad" + icon: NOT_A_MATERIAL + criteria: + type: RING + ring: 1 + bad-counter: + name: "Bad" + icon: STONE + criteria: + type: COUNTER + counter: NOT_A_COUNTER + threshold: 1 + no-criteria: + name: "Bad" + icon: STONE + """; + + @Mock + private ChunkBlock addon; + @Mock + private ChunkManager cm; + @Mock + private ActivityManager am; + @Mock + private BlockListener blockListener; + @Mock + private PlayersManager playersManager; + + private OneBlockIslands data; + private TrophyManager tm; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + when(addon.getChunkManager()).thenReturn(cm); + when(addon.getActivityManager()).thenReturn(am); + when(addon.getBlockListener()).thenReturn(blockListener); + when(addon.getPlayers()).thenReturn(playersManager); + when(playersManager.getName(any())).thenReturn("tastybento"); + data = new OneBlockIslands("island-id"); + when(addon.getOneBlocksIsland(island)).thenReturn(data); + when(island.getUniqueId()).thenReturn("island-id"); + + tm = new TrophyManager(addon); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.loadFromString(TROPHY_YAML); + tm.loadTrophies(yaml.getConfigurationSection("trophies")); + } + + @Test + void testInvalidTrophiesAreSkippedValidOnesLoad() { + assertEquals(3, tm.getTrophies().size()); + assertTrue(tm.getTrophy("first-ring").isPresent()); + assertTrue(tm.getTrophy("bad-icon").isEmpty()); + assertTrue(tm.getTrophy("bad-counter").isEmpty()); + assertTrue(tm.getTrophy("no-criteria").isEmpty()); + verify(addon, times(3)).logError(anyString()); + } + + @Test + void testNothingAwardedWhenNothingIsMet() { + tm.check(island); + assertTrue(data.getEarnedTrophies().isEmpty()); + verify(pim, never()).callEvent(any(TrophyAwardEvent.class)); + } + + @Test + void testRingTrophyAwardedOnceAndOnlyOnce() { + when(cm.completedRings(island)).thenReturn(1); + tm.check(island); + assertTrue(data.getEarnedTrophies().contains("first-ring")); + verify(pim, times(1)).callEvent(any(TrophyAwardEvent.class)); + verify(blockListener).saveIsland(island); + // Earned once, stays earned: a re-lock and re-claim cannot re-award + tm.check(island); + verify(pim, times(1)).callEvent(any(TrophyAwardEvent.class)); + } + + @Test + void testIslandCounterTrophy() { + when(am.getCount(island, null, CounterType.CHUNKS_CLAIMED, 0)).thenReturn(10L); + tm.check(island); + assertTrue(data.getEarnedTrophies().contains("homesteader")); + assertFalse(data.getEarnedTrophies().contains("breaker")); + } + + @Test + void testMemberCounterTrophyReadsEachMembersOwnCount() { + UUID mate = UUID.randomUUID(); + when(am.getContributors(island)).thenReturn(Set.of(uuid, mate)); + // The island total would pass a naive check, but no single member has 100 + when(am.getCount(eq(island), any(UUID.class), eq(CounterType.MAGIC_BLOCKS), anyInt())) + .thenReturn(60L); + tm.check(island); + assertFalse(data.getEarnedTrophies().contains("breaker")); + // Now one member crosses the line, inside the 7-day window + when(am.getCount(island, mate, CounterType.MAGIC_BLOCKS, 7)).thenReturn(100L); + tm.check(island); + assertTrue(data.getEarnedTrophies().contains("breaker")); + } + + @Test + void testAwardEventCarriesTheTrophy() { + when(cm.completedRings(island)).thenReturn(1); + tm.check(island); + ArgumentCaptor captor = ArgumentCaptor.forClass(TrophyAwardEvent.class); + verify(pim).callEvent(captor.capture()); + assertEquals("first-ring", captor.getValue().getTrophy().id()); + assertEquals(island, captor.getValue().getIsland()); + } + + @Test + void testCancelledAwardStaysEarnedButRunsNoCommands() { + doAnswer(invocation -> { + invocation.getArgument(0, TrophyAwardEvent.class).setCancelled(true); + return null; + }).when(pim).callEvent(any(TrophyAwardEvent.class)); + when(cm.completedRings(island)).thenReturn(1); + tm.check(island); + // The trophy persists — it can never be awarded twice — but the addon did not act + assertTrue(data.getEarnedTrophies().contains("first-ring")); + mockedBukkit.verify(() -> Bukkit.dispatchCommand(any(), anyString()), never()); + tm.check(island); + verify(pim, times(1)).callEvent(any(TrophyAwardEvent.class)); + } + + @Test + void testRewardCommandsRunWithPlaceholders() { + when(cm.completedRings(island)).thenReturn(1); + tm.check(island); + mockedBukkit.verify(() -> Bukkit.dispatchCommand(any(), eq("eco give tastybento 100"))); + } + + @Test + void testFirstTitledTrophyBecomesActiveTitle() { + when(cm.completedRings(island)).thenReturn(1); + tm.check(island); + assertEquals("first-ring", data.getActiveTitle()); + assertEquals("Ring Bearer", tm.getActiveTitleText(island)); + } + + @Test + void testUntitledTrophyDoesNotSetTitle() { + when(am.getCount(island, null, CounterType.CHUNKS_CLAIMED, 0)).thenReturn(10L); + tm.check(island); + assertTrue(data.getEarnedTrophies().contains("homesteader")); + assertEquals("", data.getActiveTitle()); + assertEquals("", tm.getActiveTitleText(island)); + } + + @Test + void testSetActiveTitleValidation() { + // Not earned yet + assertFalse(tm.setActiveTitle(island, "first-ring")); + // Unknown trophy + assertFalse(tm.setActiveTitle(island, "nope")); + data.getEarnedTrophies().add("homesteader"); + // Earned but carries no title + assertFalse(tm.setActiveTitle(island, "homesteader")); + data.getEarnedTrophies().add("first-ring"); + assertTrue(tm.setActiveTitle(island, "first-ring")); + assertEquals("first-ring", data.getActiveTitle()); + // Clearing always works + assertTrue(tm.setActiveTitle(island, null)); + assertEquals("", data.getActiveTitle()); + } + + @Test + void testGetEarnedKeepsConfigOrder() { + data.getEarnedTrophies().add("breaker"); + data.getEarnedTrophies().add("first-ring"); + assertEquals(2, tm.getEarned(island).size()); + assertEquals("first-ring", tm.getEarned(island).get(0).id()); + assertEquals("breaker", tm.getEarned(island).get(1).id()); + } + + @Test + void testActiveTitleOfRemovedTrophyIsEmpty() { + data.getEarnedTrophies().add("first-ring"); + data.setActiveTitle("first-ring"); + // The admin removes the trophy from trophies.yml and reloads + tm.loadTrophies(mock(org.bukkit.configuration.ConfigurationSection.class)); + assertEquals("", tm.getActiveTitleText(island)); + } +}