Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/main/java/world/bentobox/chunkblock/ChunkBlock.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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");
Expand All @@ -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)
*/
Expand Down
22 changes: 22 additions & 0 deletions src/main/java/world/bentobox/chunkblock/Settings.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2671,6 +2679,20 @@ public void setRingPlayerCommands(List<String> 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
*/
Expand Down
222 changes: 222 additions & 0 deletions src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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<IslandActivity> handler;
private final Map<String, IslandActivity> cache = new HashMap<>();
/** Unsaved frequent-counter records per island, so block breaks don't save every hit */
private final Map<String, Integer> unsavedCounts = new HashMap<>();

/** Today as an epoch day; replaceable so tests can move time */
private LongSupplier daySupplier = () -> LocalDate.now().toEpochDay();

Check warning on line 47 in src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Explicitly specify the time zone by passing a ZoneId or a Clock to the .now() method.

See more on https://sonarcloud.io/project/issues?id=BentoBoxWorld_ChunkBlock&issues=AaADNm7vk1xzxvxQWcr0&open=AaADNm7vk1xzxvxQWcr0&pullRequest=35

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, &gt; 0 (anything else is ignored)
*/
public void record(@NonNull Island island, @Nullable UUID member, @NonNull CounterType type, long amount) {

Check warning on line 72 in src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this method to not match a restricted identifier.

See more on https://sonarcloud.io/project/issues?id=BentoBoxWorld_ChunkBlock&issues=AaADNm7vk1xzxvxQWcr1&open=AaADNm7vk1xzxvxQWcr1&pullRequest=35
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<UUID> getContributors(@NonNull Island island) {
Set<UUID> 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);
}
}
34 changes: 34 additions & 0 deletions src/main/java/world/bentobox/chunkblock/activity/CounterType.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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
}
Loading
Loading