From f58395c28cb7336eb4de976d5a6e7ed903df61bb Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 14 Aug 2026 19:15:43 -0700 Subject: [PATCH] feat: per-member island activity counters The counting substrate that a contribution ledger (#7), a season leaderboard (#10) and a relay score (#9) all read from, built once instead of three times. Counters are keyed on (island, member): activity is only credited while the player is actually on the island's team, so recruiting a veteran cannot import old progress, and a member who leaves keeps their contribution credited to the island they made it on. Activity no member can be credited with (level changes, re-locks, minion breaks) is recorded at island scope. Each counter stores a lifetime total plus per-day buckets, so daily, weekly and season windows are all sums over the same data. Buckets older than a configurable retention (default 100 days) are pruned; lifetime totals never are. "Chunks claimed" means distinct chunks: a claimedEver set that survives re-locks tells a first claim from the recovery of re-locked territory, so nothing is counted twice. Writers observe the addon's own events at MONITOR priority; ChunkUnlockEvent now carries the claiming player's UUID. A new member-activity request handler lets other plugins ask e.g. how many chunks a member claimed for their island in the last 7 days. Foundation for #30. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W8GghDu6oXiUf6eCoimJqD --- .../world/bentobox/chunkblock/ChunkBlock.java | 22 ++ .../world/bentobox/chunkblock/Settings.java | 22 ++ .../chunkblock/activity/ActivityManager.java | 222 ++++++++++++++++ .../chunkblock/activity/CounterType.java | 34 +++ .../dataobjects/IslandActivity.java | 236 ++++++++++++++++++ .../chunkblock/events/ChunkUnlockEvent.java | 25 ++ .../listeners/ActivityListener.java | 108 ++++++++ .../listeners/ChunkClaimListener.java | 2 +- .../chunkblock/listeners/LevelListener.java | 12 +- .../requests/MemberActivityHandler.java | 72 ++++++ src/main/resources/config.yml | 7 + .../activity/ActivityManagerTest.java | 219 ++++++++++++++++ .../listeners/ActivityListenerTest.java | 134 ++++++++++ .../listeners/ChunkClaimListenerTest.java | 6 +- .../listeners/LevelListenerTest.java | 8 +- .../requests/MemberActivityHandlerTest.java | 77 ++++++ 16 files changed, 1196 insertions(+), 10 deletions(-) create mode 100644 src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java create mode 100644 src/main/java/world/bentobox/chunkblock/activity/CounterType.java create mode 100644 src/main/java/world/bentobox/chunkblock/dataobjects/IslandActivity.java create mode 100644 src/main/java/world/bentobox/chunkblock/listeners/ActivityListener.java create mode 100644 src/main/java/world/bentobox/chunkblock/requests/MemberActivityHandler.java create mode 100644 src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java create mode 100644 src/test/java/world/bentobox/chunkblock/listeners/ActivityListenerTest.java create mode 100644 src/test/java/world/bentobox/chunkblock/requests/MemberActivityHandlerTest.java diff --git a/src/main/java/world/bentobox/chunkblock/ChunkBlock.java b/src/main/java/world/bentobox/chunkblock/ChunkBlock.java index 8df0ff6..4346765 100644 --- a/src/main/java/world/bentobox/chunkblock/ChunkBlock.java +++ b/src/main/java/world/bentobox/chunkblock/ChunkBlock.java @@ -13,8 +13,10 @@ import org.eclipse.jdt.annotation.NonNull; import org.eclipse.jdt.annotation.Nullable; +import world.bentobox.chunkblock.activity.ActivityManager; import world.bentobox.chunkblock.chunks.BorderDisplay; import world.bentobox.chunkblock.chunks.ChunkManager; +import world.bentobox.chunkblock.listeners.ActivityListener; import world.bentobox.chunkblock.commands.admin.AdminCommand; import world.bentobox.chunkblock.commands.island.PlayerCommand; import world.bentobox.chunkblock.dataobjects.OneBlockIslands; @@ -40,6 +42,7 @@ import world.bentobox.chunkblock.oneblocks.customblock.ItemsAdderCustomBlock; import world.bentobox.chunkblock.oneblocks.customblock.NexoCustomBlock; import world.bentobox.chunkblock.requests.IslandStatsHandler; +import world.bentobox.chunkblock.requests.MemberActivityHandler; import world.bentobox.chunkblock.requests.UnlockedChunksHandler; import world.bentobox.chunkblock.requests.LocationStatsHandler; import world.bentobox.bentobox.api.addons.GameModeAddon; @@ -88,6 +91,8 @@ public class ChunkBlock extends GameModeAddon { private OneBlocksManager oneBlockManager; /** The manager for chunk locking and the unlock spiral */ private ChunkManager chunkManager; + /** The per-member activity counters (the ledger/leaderboard/trophy substrate) */ + private ActivityManager activityManager; /** The placeholder manager for ChunkBlock */ private ChunkBlockPlaceholders phManager; /** The listener for hologram-related events */ @@ -244,6 +249,8 @@ public void onEnable() { oneBlockManager = new OneBlocksManager(this); // Initialize the chunk lock manager chunkManager = new ChunkManager(this); + // Initialize the activity counters + activityManager = new ActivityManager(this); // Load phase data if (loadData()) { // Failed to load - don't register anything @@ -265,6 +272,7 @@ public void onEnable() { registerListener(new BlockProtect(this)); registerListener(new JoinLeaveListener(this)); registerListener(new InfoListener(this)); + registerListener(new ActivityListener(this)); // Note: bossBar is registered as a listener by the FlagsManager when the // CHUNKBLOCK_BOSSBAR or CHUNKBLOCK_ACTIONBAR flag is registered in onLoad, so it // must not be registered here too or events would be handled twice @@ -275,6 +283,7 @@ public void onEnable() { registerRequestHandler(new IslandStatsHandler(this)); registerRequestHandler(new LocationStatsHandler(this)); registerRequestHandler(new UnlockedChunksHandler(this)); + registerRequestHandler(new MemberActivityHandler(this)); // Register Holograms holoListener = new HoloListener(this); @@ -309,6 +318,9 @@ public void onDisable() { if (blockListener != null) { blockListener.saveCacheNow(); } + if (activityManager != null) { + activityManager.saveCacheNow(); + } // Stop border rendering and restore client-side blocks if (borderDisplay != null) { @@ -325,6 +337,9 @@ public void onDisable() { public void onReload() { // save cache blockListener.saveCache(); + if (activityManager != null) { + activityManager.saveCache(); + } // Reload settings and phase data if (loadSettings()) { log("Reloaded ChunkBlock settings"); @@ -346,6 +361,13 @@ public ChunkManager getChunkManager() { return chunkManager; } + /** + * @return the activity counter manager, or null before the addon is enabled + */ + public ActivityManager getActivityManager() { + return activityManager; + } + /** * @return the chunk guard listener (containment and backtracking) */ diff --git a/src/main/java/world/bentobox/chunkblock/Settings.java b/src/main/java/world/bentobox/chunkblock/Settings.java index 2600545..bc5a338 100644 --- a/src/main/java/world/bentobox/chunkblock/Settings.java +++ b/src/main/java/world/bentobox/chunkblock/Settings.java @@ -169,6 +169,14 @@ public class Settings implements WorldSettings { @ConfigEntry(path = "chunkblock.eject-players-on-relock") private boolean ejectPlayersOnRelock = true; + @ConfigComment("How many days of per-day activity buckets to keep per island. Activity counters") + @ConfigComment("(blocks broken, chunks claimed, levels earned... per member) store a lifetime") + @ConfigComment("total plus one bucket per day so time-windowed readouts are possible; buckets") + @ConfigComment("older than this are pruned. Lifetime totals are never pruned. Size this to the") + @ConfigComment("longest window anything reads — a season, a weekly ledger. Minimum 1.") + @ConfigEntry(path = "chunkblock.activity.daily-retention-days") + private int activityRetentionDays = 100; + @ConfigComment("Cancel natural mob spawning inside locked chunks.") @ConfigEntry(path = "chunkblock.deny-mob-spawns-in-locked") private boolean denyMobSpawnsInLocked = true; @@ -2671,6 +2679,20 @@ public void setRingPlayerCommands(List ringPlayerCommands) { this.ringPlayerCommands = ringPlayerCommands; } + /** + * @return how many days of per-day activity buckets are kept, never less than 1 + */ + public int getActivityRetentionDays() { + return Math.max(1, activityRetentionDays); + } + + /** + * @param activityRetentionDays the activityRetentionDays to set + */ + public void setActivityRetentionDays(int activityRetentionDays) { + this.activityRetentionDays = activityRetentionDays; + } + /** * @return true if a chunk must be previewed and confirmed before credit is spent */ diff --git a/src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java b/src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java new file mode 100644 index 0000000..4fdd2ff --- /dev/null +++ b/src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java @@ -0,0 +1,222 @@ +package world.bentobox.chunkblock.activity; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.LongSupplier; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; + +import world.bentobox.bentobox.database.Database; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.chunkblock.ChunkBlock; +import world.bentobox.chunkblock.dataobjects.IslandActivity; + +/** + * Records and answers questions about per-member island activity: the counting layer that + * a contribution ledger, a season leaderboard or a trophy condition reads from. Counters + * are data, not reward — nothing here grants anything. + *

+ * Attribution rules: an amount recorded with a member is only kept while that player is + * actually on the island's team, so a veteran joining cannot import progress and a member + * who leaves keeps their contribution credited to the island they made it on. Activity no + * member can be credited with (level changes, re-locks, minion breaks) is recorded at + * island scope instead. + * + * @author tastybento + */ +public class ActivityManager { + + /** Member key for activity that cannot be attributed to a single member */ + public static final String ISLAND_SCOPE = "island"; + + /** How many frequent-counter records may accumulate before an async save is forced */ + private static final int SAVE_EVERY = 20; + + private final ChunkBlock addon; + private final Database handler; + private final Map cache = new HashMap<>(); + /** 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(); + + public ActivityManager(ChunkBlock addon) { + this.addon = addon; + handler = new Database<>(addon, IslandActivity.class); + } + + /** + * Replaces the day source. Used only for testing. + * + * @param daySupplier supplier of today as an epoch day + */ + public void setDaySupplier(LongSupplier daySupplier) { + this.daySupplier = daySupplier; + } + + /** + * Records activity against an island. An amount attributed to a player who is not a + * member of the island's team is dropped — that is the point, not an oversight. + * + * @param island the island the activity happened on + * @param member the member who performed it, or null for island scope + * @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) { + if (amount <= 0 || (member != null && !island.getMemberSet().contains(member))) { + return; + } + IslandActivity data = getActivity(island.getUniqueId()); + long today = daySupplier.getAsLong(); + 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); + } + + /** + * Records a chunk claim, distinguishing a first-ever claim from the recovery of a + * chunk lost to a re-lock. "Chunks claimed" means distinct chunks: an island that + * loses a chunk and claims it back scores a recovery, never a second claim. + * + * @param island the island + * @param dx chunk x offset relative to the center chunk + * @param dz chunk z offset relative to the center chunk + * @param member the claiming member, or null if unknown + * @return true if this was the chunk's first-ever claim + */ + 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 + if (first) { + save(data, false); + } + return first; + } + + /** + * Answers "how much of this did this member do for this island in the last N days" — + * or in their lifetime on the team. + * + * @param island the island + * @param member the member, or null for the island total across every member and the + * island scope + * @param type the counter + * @param windowDays how many days back to count, today included; 0 or less means + * lifetime. Days beyond the configured retention have been pruned and count 0. + * @return the amount + */ + public long getCount(@NonNull Island island, @Nullable UUID member, @NonNull CounterType type, int windowDays) { + IslandActivity data = getActivity(island.getUniqueId()); + if (windowDays <= 0) { + return member == null ? data.getLifetimeTotal(type.name()) + : data.getLifetime(member.toString(), type.name()); + } + long today = daySupplier.getAsLong(); + long from = today - windowDays + 1; + return member == null ? data.sumWindowTotal(type.name(), from, today) + : data.sumWindow(member.toString(), type.name(), from, today); + } + + /** + * @param island the island + * @return every player who has recorded activity on this island, present or past + * members alike + */ + @NonNull + public Set getContributors(@NonNull Island island) { + Set result = new HashSet<>(); + for (String key : getActivity(island.getUniqueId()).getMemberKeys()) { + if (!ISLAND_SCOPE.equals(key)) { + try { + result.add(UUID.fromString(key)); + } catch (IllegalArgumentException e) { + // Not a player key — ignore + } + } + } + return result; + } + + /** + * Wipes an island's activity. Called on island create and reset — a fresh start owes + * nothing to the old island's history. + * + * @param islandId the island's uniqueId + */ + public void resetIsland(@NonNull String islandId) { + IslandActivity fresh = new IslandActivity(islandId); + cache.put(islandId, fresh); + unsavedCounts.remove(islandId); + handler.saveObjectAsync(fresh); + } + + /** + * Deletes an island's activity outright. + * + * @param islandId the island's uniqueId + */ + public void deleteIsland(@NonNull String islandId) { + cache.remove(islandId); + unsavedCounts.remove(islandId); + handler.deleteID(islandId); + } + + /** + * Saves all cached activity asynchronously. Only safe while the server is running; on + * shutdown use {@link #saveCacheNow()}. + */ + public void saveCache() { + cache.values().forEach(handler::saveObjectAsync); + unsavedCounts.clear(); + } + + /** + * Saves all cached activity on the calling thread. Used on shutdown, where a queued + * asynchronous save could be lost — same reasoning as the block-count cache. + */ + public void saveCacheNow() { + cache.values().forEach(handler::saveObjectNow); + unsavedCounts.clear(); + } + + @NonNull + private IslandActivity getActivity(@NonNull String islandId) { + IslandActivity data = cache.get(islandId); + if (data != null) { + return data; + } + if (handler.objectExists(islandId)) { + data = handler.loadObject(islandId); + } + if (data == null) { + data = new IslandActivity(islandId); + } + cache.put(islandId, data); + return data; + } + + /** + * Saves now, unless this is a high-frequency counter, which saves every + * {@link #SAVE_EVERY} records instead. Everything left over is flushed by the cache + * saves on reload and shutdown. + */ + private void save(IslandActivity data, boolean throttled) { + if (throttled) { + int count = unsavedCounts.merge(data.getUniqueId(), 1, Integer::sum); + if (count < SAVE_EVERY) { + return; + } + } + unsavedCounts.remove(data.getUniqueId()); + handler.saveObjectAsync(data); + } +} diff --git a/src/main/java/world/bentobox/chunkblock/activity/CounterType.java b/src/main/java/world/bentobox/chunkblock/activity/CounterType.java new file mode 100644 index 0000000..40f1e39 --- /dev/null +++ b/src/main/java/world/bentobox/chunkblock/activity/CounterType.java @@ -0,0 +1,34 @@ +package world.bentobox.chunkblock.activity; + +/** + * The activity counters recorded per (island, member). These are data, not reward: + * ledgers, leaderboards and trophies all read from them, but nothing here pays anything + * out. + *

+ * Counters attributed to a member are only recorded while that player is actually on the + * island's team, so recruiting a veteran cannot import old progress. Counters an event + * cannot attribute to a member (an island level change, a re-lock) are recorded at island + * scope instead. + * + * @author tastybento + */ +public enum CounterType { + /** Magic blocks broken at the island's center. Island scope when broken by a minion. */ + MAGIC_BLOCKS, + /** + * Island levels gained. Always island scope: the Level addon reports the island's + * level as a whole and cannot attribute a change to a member. + */ + LEVELS_EARNED, + /** + * Chunks claimed that the island had never claimed before — distinct chunks, so + * re-locking a chunk and claiming it back never counts twice. + */ + CHUNKS_CLAIMED, + /** Claims of chunks the island had claimed before: territory recovered after a re-lock. */ + CHUNKS_RECLAIMED, + /** Chunks lost to level loss. Island scope — nobody performs a re-lock. */ + CHUNKS_RELOCKED, + /** Rings completed for the first time. Island scope, one per ring per island. */ + RINGS_COMPLETED +} diff --git a/src/main/java/world/bentobox/chunkblock/dataobjects/IslandActivity.java b/src/main/java/world/bentobox/chunkblock/dataobjects/IslandActivity.java new file mode 100644 index 0000000..62cd75a --- /dev/null +++ b/src/main/java/world/bentobox/chunkblock/dataobjects/IslandActivity.java @@ -0,0 +1,236 @@ +package world.bentobox.chunkblock.dataobjects; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNull; + +import com.google.gson.annotations.Expose; + +import world.bentobox.bentobox.database.objects.DataObject; +import world.bentobox.bentobox.database.objects.Table; + +/** + * Per-island activity counters, keyed by (member, counter): the counting substrate that + * ledgers, leaderboards and trophies read from. One object per island, uniqueId is the + * island's uniqueId. + *

+ * Counters are stored twice: a lifetime running total, and per-day buckets so time + * windows (daily, weekly, a season) can be summed. Old daily buckets are pruned after a + * configurable retention period; pruning never touches the lifetime totals. + *

+ * Both maps are deliberately flat {@code String -> Long} with composite keys — every + * database backend serializes that shape without custom adapters. Keys are + * {@code member|COUNTER} for lifetime and {@code member|COUNTER|epochDay} for daily + * buckets, where {@code member} is a player UUID or the island-scope key for activity no + * member can be credited with. + * + * @author tastybento + */ +@Table(name = "ChunkBlockActivity") +public class IslandActivity implements DataObject { + + /** Key separator inside composite map keys. Never appears in a UUID or counter name. */ + private static final char SEP = '|'; + + @Expose + private String uniqueId; + + /** Lifetime totals: {@code member|COUNTER -> amount} */ + @Expose + private Map lifetime = new HashMap<>(); + + /** Daily buckets: {@code member|COUNTER|epochDay -> amount} */ + @Expose + private Map daily = new HashMap<>(); + + /** + * Every chunk offset ("dx,dz") this island has ever claimed, so a chunk recovered + * after a re-lock can be told apart from a first claim. Cleared only on island + * create or reset — surviving admin re-locks is the point. + */ + @Expose + private Set claimedEver = new HashSet<>(); + + public IslandActivity() { + // Required by the database loader + } + + public IslandActivity(String uniqueId) { + this.uniqueId = uniqueId; + } + + @Override + public String getUniqueId() { + return uniqueId; + } + + @Override + public void setUniqueId(String uniqueId) { + this.uniqueId = uniqueId; + } + + /** + * @return the lifetime totals map, never null + */ + @NonNull + public Map getLifetime() { + if (lifetime == null) { + lifetime = new HashMap<>(); + } + return lifetime; + } + + public void setLifetime(Map lifetime) { + this.lifetime = lifetime; + } + + /** + * @return the daily buckets map, never null + */ + @NonNull + public Map getDaily() { + if (daily == null) { + daily = new HashMap<>(); + } + return daily; + } + + public void setDaily(Map daily) { + this.daily = daily; + } + + /** + * @return the set of chunk offsets ever claimed, never null + */ + @NonNull + public Set getClaimedEver() { + if (claimedEver == null) { + claimedEver = new HashSet<>(); + } + return claimedEver; + } + + public void setClaimedEver(Set claimedEver) { + this.claimedEver = claimedEver; + } + + /** + * Adds an amount to a member's counter: the lifetime total and today's bucket. + * + * @param memberKey player UUID string, or the island-scope key + * @param counter counter name + * @param epochDay today as an epoch day + * @param amount amount to add, > 0 + */ + public void add(String memberKey, String counter, long epochDay, long amount) { + getLifetime().merge(memberKey + SEP + counter, amount, Long::sum); + getDaily().merge(memberKey + SEP + counter + SEP + epochDay, amount, Long::sum); + } + + /** + * @param memberKey player UUID string, or the island-scope key + * @param counter counter name + * @return the member's lifetime total for the counter + */ + public long getLifetime(String memberKey, String counter) { + return getLifetime().getOrDefault(memberKey + SEP + counter, 0L); + } + + /** + * @param counter counter name + * @return the island's lifetime total for the counter across every member and the + * island scope + */ + public long getLifetimeTotal(String counter) { + String suffix = SEP + counter; + return getLifetime().entrySet().stream().filter(e -> e.getKey().endsWith(suffix)) + .mapToLong(Map.Entry::getValue).sum(); + } + + /** + * Sums a member's daily buckets over an inclusive day range. + * + * @param memberKey player UUID string, or the island-scope key + * @param counter counter name + * @param fromDay first epoch day, inclusive + * @param toDay last epoch day, inclusive + * @return the summed amount; days already pruned contribute nothing + */ + public long sumWindow(String memberKey, String counter, long fromDay, long toDay) { + String prefix = memberKey + SEP + counter + SEP; + return sumBuckets(prefix, prefix.length(), fromDay, toDay); + } + + /** + * Sums the island's daily buckets over an inclusive day range across every member and + * the island scope. + * + * @param counter counter name + * @param fromDay first epoch day, inclusive + * @param toDay last epoch day, inclusive + * @return the summed amount; days already pruned contribute nothing + */ + public long sumWindowTotal(String counter, long fromDay, long toDay) { + String infix = SEP + counter + SEP; + long sum = 0; + for (Map.Entry e : getDaily().entrySet()) { + int at = e.getKey().indexOf(infix); + if (at >= 0 && inRange(e.getKey(), at + infix.length(), fromDay, toDay)) { + sum += e.getValue(); + } + } + return sum; + } + + private long sumBuckets(String prefix, int dayStart, long fromDay, long toDay) { + long sum = 0; + for (Map.Entry e : getDaily().entrySet()) { + if (e.getKey().startsWith(prefix) && inRange(e.getKey(), dayStart, fromDay, toDay)) { + sum += e.getValue(); + } + } + return sum; + } + + private boolean inRange(String key, int dayStart, long fromDay, long toDay) { + long day = parseDay(key, dayStart); + return day >= fromDay && day <= toDay; + } + + private long parseDay(String key, int dayStart) { + try { + return Long.parseLong(key.substring(dayStart)); + } catch (NumberFormatException e) { + return Long.MIN_VALUE; + } + } + + /** + * Removes every daily bucket older than the given day. Lifetime totals are untouched: + * pruning only limits how far back a window can reach. + * + * @param oldestKeptDay the oldest epoch day to keep + */ + public void prune(long oldestKeptDay) { + getDaily().keySet() + .removeIf(key -> parseDay(key, key.lastIndexOf(SEP) + 1) < oldestKeptDay); + } + + /** + * @return every member key that has a lifetime total, island scope included + */ + @NonNull + public Set getMemberKeys() { + Set keys = new HashSet<>(); + for (String key : getLifetime().keySet()) { + int at = key.indexOf(SEP); + if (at > 0) { + keys.add(key.substring(0, at)); + } + } + return keys; + } +} diff --git a/src/main/java/world/bentobox/chunkblock/events/ChunkUnlockEvent.java b/src/main/java/world/bentobox/chunkblock/events/ChunkUnlockEvent.java index b58af23..c86bcfd 100644 --- a/src/main/java/world/bentobox/chunkblock/events/ChunkUnlockEvent.java +++ b/src/main/java/world/bentobox/chunkblock/events/ChunkUnlockEvent.java @@ -1,8 +1,11 @@ package world.bentobox.chunkblock.events; +import java.util.UUID; + import org.bukkit.event.HandlerList; import org.bukkit.util.Vector; import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; import world.bentobox.bentobox.api.events.BentoBoxEvent; import world.bentobox.bentobox.database.objects.Island; @@ -20,6 +23,7 @@ public class ChunkUnlockEvent extends BentoBoxEvent { private final Island island; private final Vector chunkOffset; private final int unlockIndex; + private final UUID playerUUID; /** * @param island the island that unlocked the chunk @@ -27,9 +31,30 @@ public class ChunkUnlockEvent extends BentoBoxEvent { * @param unlockIndex the chunk's position in the island's unlock order */ public ChunkUnlockEvent(@NonNull Island island, @NonNull Vector chunkOffset, int unlockIndex) { + this(island, chunkOffset, unlockIndex, null); + } + + /** + * @param island the island that unlocked the chunk + * @param chunkOffset the chunk offset relative to the island's center chunk (x and z) + * @param unlockIndex the chunk's position in the island's unlock order + * @param playerUUID the player who spent the credit, or null if no player did (an + * admin action, for example) + */ + public ChunkUnlockEvent(@NonNull Island island, @NonNull Vector chunkOffset, int unlockIndex, + @Nullable UUID playerUUID) { this.island = island; this.chunkOffset = chunkOffset; this.unlockIndex = unlockIndex; + this.playerUUID = playerUUID; + } + + /** + * @return the player who spent the credit on this chunk, or null if no player did + */ + @Nullable + public UUID getPlayerUUID() { + return playerUUID; } @Override diff --git a/src/main/java/world/bentobox/chunkblock/listeners/ActivityListener.java b/src/main/java/world/bentobox/chunkblock/listeners/ActivityListener.java new file mode 100644 index 0000000..9b47669 --- /dev/null +++ b/src/main/java/world/bentobox/chunkblock/listeners/ActivityListener.java @@ -0,0 +1,108 @@ +package world.bentobox.chunkblock.listeners; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +import world.bentobox.bentobox.api.events.island.IslandCreatedEvent; +import world.bentobox.bentobox.api.events.island.IslandDeleteEvent; +import world.bentobox.bentobox.api.events.island.IslandResettedEvent; +import world.bentobox.chunkblock.ChunkBlock; +import world.bentobox.chunkblock.activity.CounterType; +import world.bentobox.chunkblock.events.ChunkRelockEvent; +import world.bentobox.chunkblock.events.ChunkUnlockEvent; +import world.bentobox.chunkblock.events.MagicBlockEvent; +import world.bentobox.chunkblock.events.RingCompleteEvent; + +/** + * Feeds the addon's own events into the activity counters. Everything here runs at + * MONITOR: counters observe what happened, they never change it. + *

+ * Level gains are the one write not routed through this listener — the level delta is + * only known inside {@code LevelListener#applyLevel}, which records it directly. + * + * @author tastybento + */ +public class ActivityListener implements Listener { + + private final ChunkBlock addon; + + public ActivityListener(ChunkBlock addon) { + this.addon = addon; + } + + /** + * A magic block was broken: credited to the breaking member, or to the island itself + * when a minion did the breaking. + */ + @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); + } + } + + /** + * A chunk was claimed: a first-ever claim and the recovery of a re-locked chunk are + * scored as different counters, so nothing is ever counted twice. + */ + @EventHandler(priority = EventPriority.MONITOR) + public void onChunkUnlock(ChunkUnlockEvent e) { + if (addon.inWorld(e.getIsland().getWorld())) { + addon.getActivityManager().recordClaim(e.getIsland(), e.getChunkOffset().getBlockX(), + e.getChunkOffset().getBlockZ(), e.getPlayerUUID()); + } + } + + /** + * A chunk was lost to level loss. Island scope — nobody performs a re-lock. + */ + @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); + } + } + + /** + * A ring was completed for the first time. Counted even if a plugin cancels the + * addon's reward handling: the completion is a fact, and counters are data, not + * reward. + */ + @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); + } + } + + /** + * A brand-new island starts with an empty ledger. + */ + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onIslandCreated(IslandCreatedEvent e) { + if (addon.inWorld(e.getIsland().getWorld())) { + addon.getActivityManager().resetIsland(e.getIsland().getUniqueId()); + } + } + + /** + * A reset island starts its ledger over. + */ + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onIslandResetted(IslandResettedEvent e) { + if (addon.inWorld(e.getIsland().getWorld())) { + addon.getActivityManager().resetIsland(e.getIsland().getUniqueId()); + } + } + + /** + * A deleted island takes its ledger with it. + */ + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onIslandDeleted(IslandDeleteEvent e) { + if (addon.inWorld(e.getIsland().getWorld())) { + addon.getActivityManager().deleteIsland(e.getIsland().getUniqueId()); + } + } +} diff --git a/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java b/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java index 3b330e8..04e0f34 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java @@ -224,7 +224,7 @@ public void attemptClaim(User user, Island island, int chunkX, int chunkZ) { switch (result) { case OK -> { clearPending(user.getUniqueId()); - addon.getLevelListener().celebrateClaim(island, chunkX, chunkZ); + addon.getLevelListener().celebrateClaim(island, chunkX, chunkZ, user.getUniqueId()); } case NO_CREDIT -> { if (feedbackReady(user.getUniqueId())) { diff --git a/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java b/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java index ce39009..def1933 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java @@ -12,6 +12,7 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.util.Vector; +import org.eclipse.jdt.annotation.Nullable; import world.bentobox.bentobox.api.events.island.IslandCreatedEvent; import world.bentobox.bentobox.api.events.island.IslandResettedEvent; @@ -19,6 +20,7 @@ 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.chunks.ChunkManager; import world.bentobox.chunkblock.dataobjects.OneBlockIslands; import world.bentobox.chunkblock.events.ChunkRelockEvent; @@ -94,6 +96,11 @@ public void applyLevel(Island island, long level) { long oldLevel = data.getLastKnownLevel(); data.setLastKnownLevel(level); addon.getBlockListener().saveIsland(island); + // 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); + } // Level dropped below what has been spent → the most recent claims are lost if (addon.getSettings().isRelockOnLevelLoss() && cm.getSpentLevels(island) > Math.max(0, level)) { relock(island, level); @@ -144,13 +151,14 @@ private void relock(Island island, long level) { * @param island the island * @param chunkX claimed world chunk x * @param chunkZ claimed world chunk z + * @param claimer the player who spent the credit, or null if no player did */ - public void celebrateClaim(Island island, int chunkX, int chunkZ) { + public void celebrateClaim(Island island, int chunkX, int chunkZ, @Nullable UUID claimer) { ChunkManager cm = addon.getChunkManager(); int count = cm.getUnlockedChunkCount(island); Vector offset = new Vector(chunkX - (island.getCenter().getBlockX() >> 4), 0, chunkZ - (island.getCenter().getBlockZ() >> 4)); - Bukkit.getPluginManager().callEvent(new ChunkUnlockEvent(island, offset, count - 1)); + Bukkit.getPluginManager().callEvent(new ChunkUnlockEvent(island, offset, count - 1, claimer)); long creditLeft = Math.max(0, cm.getCredit(island)); island.getMemberSet().forEach(uuid -> { User user = User.getInstance(uuid); diff --git a/src/main/java/world/bentobox/chunkblock/requests/MemberActivityHandler.java b/src/main/java/world/bentobox/chunkblock/requests/MemberActivityHandler.java new file mode 100644 index 0000000..60b2214 --- /dev/null +++ b/src/main/java/world/bentobox/chunkblock/requests/MemberActivityHandler.java @@ -0,0 +1,72 @@ +package world.bentobox.chunkblock.requests; + +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +import world.bentobox.bentobox.api.addons.request.AddonRequestHandler; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.chunkblock.ChunkBlock; +import world.bentobox.chunkblock.activity.CounterType; + +/** + * Answers "how much of this activity did this member do for this island in the last N + * days" for other plugins.
+ * Submit to {@link #handle(Map)}: + *

    + *
  • "island-id" - String island uniqueId, OR "player" - UUID of an island member (the + * member's island in this gamemode's world is used)
  • + *
  • "member" - UUID (optional; omit for the island's total)
  • + *
  • "counter" - String, a {@link CounterType} name
  • + *
  • "days" - Integer (optional; how many days back to count, today included; omit or + * <= 0 for lifetime)
  • + *
+ * Returns a Long amount, or null if the island or counter cannot be resolved. + * + * @author tastybento + */ +public class MemberActivityHandler extends AddonRequestHandler { + + private static final String ISLAND_ID = "island-id"; + private static final String PLAYER = "player"; + private static final String MEMBER = "member"; + private static final String COUNTER = "counter"; + private static final String DAYS = "days"; + + private final ChunkBlock addon; + + public MemberActivityHandler(ChunkBlock addon) { + super("member-activity"); + this.addon = addon; + } + + @Override + public Object handle(Map map) { + if (map == null || !(map.get(COUNTER) instanceof String counterName)) { + return null; + } + CounterType counter; + try { + counter = CounterType.valueOf(counterName.toUpperCase(Locale.ENGLISH)); + } catch (IllegalArgumentException e) { + return null; + } + Island island = getIsland(map); + if (island == null) { + return null; + } + UUID member = map.get(MEMBER) instanceof UUID uuid ? uuid : null; + int days = map.get(DAYS) instanceof Number number ? number.intValue() : 0; + return addon.getActivityManager().getCount(island, member, counter, days); + } + + private Island getIsland(Map map) { + if (map.get(ISLAND_ID) instanceof String id) { + return addon.getIslands().getIslandById(id).orElse(null); + } + if (map.get(PLAYER) instanceof UUID uuid) { + return addon.getIslands().getIsland(addon.getOverWorld(), uuid); + } + return null; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 5c5aec4..c088b97 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -90,6 +90,13 @@ chunkblock: # If true, players standing in a chunk when it re-locks are moved to the nearest # unlocked spot. If false they may walk out but not back in. eject-players-on-relock: true + activity: + # How many days of per-day activity buckets to keep per island. Activity counters + # (blocks broken, chunks claimed, levels earned... per member) store a lifetime + # total plus one bucket per day so time-windowed readouts are possible; buckets + # older than this are pruned. Lifetime totals are never pruned. Size this to the + # longest window anything reads — a season, a weekly ledger. Minimum 1. + daily-retention-days: 100 # Cancel natural mob spawning inside locked chunks. deny-mob-spawns-in-locked: true # Bounce dropped items back when they cross into a locked chunk so players diff --git a/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java b/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java new file mode 100644 index 0000000..9ad5ff8 --- /dev/null +++ b/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java @@ -0,0 +1,219 @@ +package world.bentobox.chunkblock.activity; + +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.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import com.google.common.collect.ImmutableSet; + +import world.bentobox.bentobox.database.AbstractDatabaseHandler; +import world.bentobox.bentobox.database.DatabaseSetup; +import world.bentobox.chunkblock.ChunkBlock; +import world.bentobox.chunkblock.CommonTestSetup; +import world.bentobox.chunkblock.Settings; +import world.bentobox.chunkblock.dataobjects.IslandActivity; + +/** + * The spec for the activity counters: attribution, time windows, retention, and the + * distinct-claim rule that stops re-locked chunks from being counted twice. + * + * @author tastybento + */ +class ActivityManagerTest extends CommonTestSetup { + + @Mock + private ChunkBlock addon; + + private AbstractDatabaseHandler h; + private MockedStatic mockDb; + private Settings settings; + private ActivityManager am; + /** The controllable "today" */ + private long day = 20000; + + @SuppressWarnings("unchecked") + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + // Database + h = mock(AbstractDatabaseHandler.class); + mockDb = Mockito.mockStatic(DatabaseSetup.class); + DatabaseSetup dbSetup = mock(DatabaseSetup.class); + mockDb.when(DatabaseSetup::getDatabase).thenReturn(dbSetup); + when(dbSetup.getHandler(any())).thenReturn(h); + when(h.saveObject(any())).thenReturn(CompletableFuture.completedFuture(true)); + when(h.saveObjectNow(any())).thenReturn(CompletableFuture.completedFuture(true)); + + settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + + when(island.getUniqueId()).thenReturn("island-id"); + + am = new ActivityManager(addon); + am.setDaySupplier(() -> day); + } + + @Override + @AfterEach + public void tearDown() throws Exception { + mockDb.closeOnDemand(); + super.tearDown(); + } + + @Test + void testRecordAndLifetime() { + am.record(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)); + // Other counters unaffected + assertEquals(0, am.getCount(island, uuid, CounterType.CHUNKS_CLAIMED, 0)); + } + + @Test + void testNonMemberIsNotCounted() { + UUID stranger = UUID.randomUUID(); + am.record(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); + assertEquals(0, am.getCount(island, uuid, CounterType.MAGIC_BLOCKS, 0)); + } + + @Test + void testIslandScopeRecording() { + am.record(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)); + } + + @Test + void testTimeWindows() { + am.record(island, uuid, CounterType.MAGIC_BLOCKS, 3); + day += 5; + am.record(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 + assertEquals(2, am.getCount(island, uuid, CounterType.MAGIC_BLOCKS, 3)); + // Last 7 days include both, and so does lifetime + assertEquals(5, am.getCount(island, uuid, CounterType.MAGIC_BLOCKS, 7)); + assertEquals(5, am.getCount(island, uuid, CounterType.MAGIC_BLOCKS, 0)); + // Island totals window the same way + assertEquals(2, am.getCount(island, null, CounterType.MAGIC_BLOCKS, 1)); + assertEquals(5, am.getCount(island, null, CounterType.MAGIC_BLOCKS, 7)); + } + + @Test + void testRetentionPrunesDailyButNotLifetime() { + settings.setActivityRetentionDays(7); + am.record(island, uuid, CounterType.MAGIC_BLOCKS, 3); + day += 10; + // Recording again prunes the now-too-old bucket + am.record(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)); + } + + @Test + void testRecordClaimDistinguishesFirstClaimFromRecovery() { + assertTrue(am.recordClaim(island, 1, 0, uuid)); + assertEquals(1, am.getCount(island, uuid, CounterType.CHUNKS_CLAIMED, 0)); + assertEquals(0, am.getCount(island, uuid, CounterType.CHUNKS_RECLAIMED, 0)); + // The chunk re-locks and is claimed back: a recovery, never a second claim + assertFalse(am.recordClaim(island, 1, 0, uuid)); + assertEquals(1, am.getCount(island, uuid, CounterType.CHUNKS_CLAIMED, 0)); + assertEquals(1, am.getCount(island, uuid, CounterType.CHUNKS_RECLAIMED, 0)); + // A different chunk is a first claim again + assertTrue(am.recordClaim(island, 0, 1, uuid)); + assertEquals(2, am.getCount(island, uuid, CounterType.CHUNKS_CLAIMED, 0)); + } + + @Test + 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); + 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)); + assertEquals(Set.of(uuid, mate), am.getContributors(island)); + } + + @Test + void testContributorsExcludeIslandScope() { + am.record(island, null, CounterType.LEVELS_EARNED, 10); + assertTrue(am.getContributors(island).isEmpty()); + } + + @Test + void testResetIslandClearsEverything() { + am.record(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)); + // The claimed-ever set was cleared too: the same chunk is a first claim again + assertTrue(am.recordClaim(island, 1, 0, uuid)); + } + + @Test + void testFrequentCounterSavesAreThrottled() throws Exception { + for (int i = 0; i < 19; i++) { + am.record(island, uuid, CounterType.MAGIC_BLOCKS, 1); + } + verify(h, never()).saveObject(any()); + am.record(island, uuid, CounterType.MAGIC_BLOCKS, 1); + verify(h).saveObject(any()); + } + + @Test + void testRareCountersSaveImmediately() throws Exception { + am.record(island, uuid, CounterType.CHUNKS_CLAIMED, 1); + verify(h).saveObject(any()); + } + + @Test + void testSaveCacheNowWritesDirectly() throws Exception { + am.record(island, uuid, CounterType.MAGIC_BLOCKS, 1); + am.saveCacheNow(); + verify(h).saveObjectNow(any()); + } + + @Test + void testDataSurvivesSerializationShape() { + // The flat map shape is the storage contract: composite keys, plain longs + IslandActivity data = new IslandActivity("x"); + data.add(uuid.toString(), CounterType.MAGIC_BLOCKS.name(), 100, 2); + assertEquals(2, data.getLifetime(uuid.toString(), "MAGIC_BLOCKS")); + assertEquals(2, data.sumWindow(uuid.toString(), "MAGIC_BLOCKS", 100, 100)); + assertEquals(2, data.getLifetimeTotal("MAGIC_BLOCKS")); + data.prune(101); + assertEquals(0, data.sumWindow(uuid.toString(), "MAGIC_BLOCKS", 0, 200)); + assertEquals(2, data.getLifetime(uuid.toString(), "MAGIC_BLOCKS")); + } +} diff --git a/src/test/java/world/bentobox/chunkblock/listeners/ActivityListenerTest.java b/src/test/java/world/bentobox/chunkblock/listeners/ActivityListenerTest.java new file mode 100644 index 0000000..0aae28d --- /dev/null +++ b/src/test/java/world/bentobox/chunkblock/listeners/ActivityListenerTest.java @@ -0,0 +1,134 @@ +package world.bentobox.chunkblock.listeners; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import org.bukkit.block.Block; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import world.bentobox.bentobox.api.events.island.IslandCreatedEvent; +import world.bentobox.bentobox.api.events.island.IslandDeleteEvent; +import world.bentobox.bentobox.api.events.island.IslandResettedEvent; +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.events.ChunkRelockEvent; +import world.bentobox.chunkblock.events.ChunkUnlockEvent; +import world.bentobox.chunkblock.events.MagicBlockEvent; +import world.bentobox.chunkblock.events.RingCompleteEvent; + +/** + * Checks that the addon's own events are routed into the right counters with the right + * attribution. + * + * @author tastybento + */ +class ActivityListenerTest extends CommonTestSetup { + + @Mock + private ChunkBlock addon; + @Mock + private ActivityManager am; + @Mock + private Block block; + + private ActivityListener listener; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + when(addon.getActivityManager()).thenReturn(am); + when(addon.inWorld(world)).thenReturn(true); + when(island.getWorld()).thenReturn(world); + when(island.getUniqueId()).thenReturn("island-id"); + listener = new ActivityListener(addon); + } + + @Test + void testMagicBlockBreakByPlayer() { + listener.onMagicBlock(new MagicBlockEvent(island, uuid, null, block, null)); + verify(am).record(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); + } + + @Test + void testChunkUnlockRecordsClaimWithClaimer() { + listener.onChunkUnlock(new ChunkUnlockEvent(island, new Vector(2, 0, -3), 5, uuid)); + verify(am).recordClaim(island, 2, -3, uuid); + } + + @Test + void testChunkRelockIsIslandScope() { + listener.onChunkRelock(new ChunkRelockEvent(island, new Vector(1, 0, 0), 3)); + verify(am).record(island, null, CounterType.CHUNKS_RELOCKED, 1); + } + + @Test + void testRingCompleteCountsEvenWhenCancelled() { + RingCompleteEvent event = new RingCompleteEvent(island, 1, 9); + event.setCancelled(true); + listener.onRingComplete(event); + verify(am).record(island, null, CounterType.RINGS_COMPLETED, 1); + } + + @Test + void testWrongWorldIsIgnored() { + when(addon.inWorld(world)).thenReturn(false); + listener.onMagicBlock(new MagicBlockEvent(island, uuid, null, block, null)); + listener.onChunkUnlock(new ChunkUnlockEvent(island, new Vector(1, 0, 0), 1, uuid)); + listener.onChunkRelock(new ChunkRelockEvent(island, new Vector(1, 0, 0), 1)); + listener.onRingComplete(new RingCompleteEvent(island, 1, 9)); + verifyNoInteractions(am); + } + + @Test + void testIslandCreatedResetsLedger() { + IslandCreatedEvent event = mock(IslandCreatedEvent.class); + when(event.getIsland()).thenReturn(island); + listener.onIslandCreated(event); + verify(am).resetIsland("island-id"); + } + + @Test + void testIslandResettedResetsLedger() { + IslandResettedEvent event = mock(IslandResettedEvent.class); + when(event.getIsland()).thenReturn(island); + listener.onIslandResetted(event); + verify(am).resetIsland("island-id"); + } + + @Test + void testIslandDeletedDeletesLedger() { + IslandDeleteEvent event = mock(IslandDeleteEvent.class); + when(event.getIsland()).thenReturn(island); + listener.onIslandDeleted(event); + verify(am).deleteIsland("island-id"); + } + + @Test + void testUnlockWithoutPlayerStillRecords() { + listener.onChunkUnlock(new ChunkUnlockEvent(island, new Vector(1, 0, 0), 1)); + verify(am).recordClaim(island, 1, 0, null); + } + + @Test + 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()); + } +} diff --git a/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java b/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java index ab70f0d..d68b2ef 100644 --- a/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java +++ b/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java @@ -120,7 +120,7 @@ void testOwnerPunchingBorderClaimsChunk() { level = 1; hitAndConfirm(Action.LEFT_CLICK_AIR); assertTrue(data.isChunkUnlocked(1, 0)); - verify(levelListener).celebrateClaim(island, 1, 0); + verify(levelListener).celebrateClaim(island, 1, 0, uuid); } @Test @@ -135,7 +135,7 @@ void testNoCreditNoClaim() { level = 0; listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); assertFalse(data.isChunkUnlocked(1, 0)); - verify(levelListener, never()).celebrateClaim(any(), anyInt(), anyInt()); + verify(levelListener, never()).celebrateClaim(any(), anyInt(), anyInt(), any()); // The player is told what they are missing verify(notifier).notify(any(), any()); } @@ -271,7 +271,7 @@ void testFirstHitOnlyPreviewsAndSpendsNothing() { level = 1; listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); assertFalse(data.isChunkUnlocked(1, 0)); - verify(levelListener, never()).celebrateClaim(any(), anyInt(), anyInt()); + verify(levelListener, never()).celebrateClaim(any(), anyInt(), anyInt(), any()); // The player is quoted a price and shown which chunk they are buying verify(notifier).notify(any(), eq("chunkblock.chunks.claim-confirm")); verify(borderDisplay).showPreview(eq(mockPlayer), eq(1), eq(0), anyLong()); diff --git a/src/test/java/world/bentobox/chunkblock/listeners/LevelListenerTest.java b/src/test/java/world/bentobox/chunkblock/listeners/LevelListenerTest.java index 1ca9391..334ed61 100644 --- a/src/test/java/world/bentobox/chunkblock/listeners/LevelListenerTest.java +++ b/src/test/java/world/bentobox/chunkblock/listeners/LevelListenerTest.java @@ -144,7 +144,7 @@ void testLevelLossWithinCreditDoesNotRelock() { void testCelebrateClaimFiresUnlockEvent() { level = 1; cm.claim(island, 1, 0); - listener.celebrateClaim(island, 1, 0); + listener.celebrateClaim(island, 1, 0, uuid); verify(pim).callEvent(any(ChunkUnlockEvent.class)); } @@ -163,7 +163,7 @@ void testPartialRingFiresNothing() { for (int[] offset : new int[][] { { 1, 0 }, { 0, 1 }, { -1, 0 }, { 0, -1 }, { 1, 1 }, { -1, 1 }, { -1, -1 } }) { cm.claim(island, offset[0], offset[1]); - listener.celebrateClaim(island, offset[0], offset[1]); + listener.celebrateClaim(island, offset[0], offset[1], uuid); } verify(pim, never()).callEvent(any(RingCompleteEvent.class)); assertEquals(0, data.getHighestRingRewarded()); @@ -180,7 +180,7 @@ void testRingIsRewardedOnlyOnceEvenAfterRelockAndReclaim() { level = 8; listener.applyLevel(island, 8); assertEquals(ClaimResult.OK, cm.claim(island, 1, -1)); - listener.celebrateClaim(island, 1, -1); + listener.celebrateClaim(island, 1, -1, uuid); assertEquals(1, data.getHighestRingRewarded()); verify(pim, times(1)).callEvent(any(RingCompleteEvent.class)); } @@ -221,7 +221,7 @@ private void claimRingOne() { for (int[] offset : new int[][] { { 1, 0 }, { 0, 1 }, { -1, 0 }, { 0, -1 }, { 1, 1 }, { -1, 1 }, { -1, -1 }, { 1, -1 } }) { assertEquals(ClaimResult.OK, cm.claim(island, offset[0], offset[1])); - listener.celebrateClaim(island, offset[0], offset[1]); + listener.celebrateClaim(island, offset[0], offset[1], uuid); } } diff --git a/src/test/java/world/bentobox/chunkblock/requests/MemberActivityHandlerTest.java b/src/test/java/world/bentobox/chunkblock/requests/MemberActivityHandlerTest.java new file mode 100644 index 0000000..78340aa --- /dev/null +++ b/src/test/java/world/bentobox/chunkblock/requests/MemberActivityHandlerTest.java @@ -0,0 +1,77 @@ +package world.bentobox.chunkblock.requests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import world.bentobox.chunkblock.ChunkBlock; +import world.bentobox.chunkblock.CommonTestSetup; +import world.bentobox.chunkblock.activity.ActivityManager; +import world.bentobox.chunkblock.activity.CounterType; + +/** + * The API check for the counters foundation: another plugin can ask "how many chunks did + * this member claim for this island in the last 7 days" and get an answer. + * + * @author tastybento + */ +class MemberActivityHandlerTest extends CommonTestSetup { + + @Mock + private ChunkBlock addon; + @Mock + private ActivityManager am; + + private MemberActivityHandler handler; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + when(addon.getIslands()).thenReturn(im); + when(addon.getOverWorld()).thenReturn(world); + when(addon.getActivityManager()).thenReturn(am); + when(im.getIslandById("island-id")).thenReturn(Optional.of(island)); + when(im.getIsland(world, uuid)).thenReturn(island); + handler = new MemberActivityHandler(addon); + } + + @Test + void testMemberWindowQueryByIslandId() { + when(am.getCount(island, uuid, CounterType.CHUNKS_CLAIMED, 7)).thenReturn(3L); + Object result = handler.handle(Map.of("island-id", "island-id", "member", uuid, + "counter", "CHUNKS_CLAIMED", "days", 7)); + assertEquals(3L, result); + } + + @Test + void testIslandLifetimeQueryByPlayer() { + when(am.getCount(island, null, CounterType.MAGIC_BLOCKS, 0)).thenReturn(500L); + Object result = handler.handle(Map.of("player", uuid, "counter", "MAGIC_BLOCKS")); + assertEquals(500L, result); + } + + @Test + void testCounterNameIsCaseInsensitive() { + when(am.getCount(island, null, CounterType.LEVELS_EARNED, 0)).thenReturn(9L); + assertEquals(9L, handler.handle(Map.of("island-id", "island-id", "counter", "levels_earned"))); + } + + @Test + void testBadInputReturnsNull() { + assertNull(handler.handle(null)); + assertNull(handler.handle(Map.of("island-id", "island-id"))); + assertNull(handler.handle(Map.of("island-id", "island-id", "counter", "NOT_A_COUNTER"))); + assertNull(handler.handle(Map.of("counter", "MAGIC_BLOCKS"))); + when(im.getIslandById("gone")).thenReturn(Optional.empty()); + assertNull(handler.handle(Map.of("island-id", "gone", "counter", "MAGIC_BLOCKS"))); + } +}