feat(spawning): add spawnable block overlay
Release / release (push) Successful in 2m16s
CI / build (push) Successful in 1m3s

This commit is contained in:
dmg
2026-08-10 22:46:02 -04:00
parent 26aeb8c416
commit 259ea9410c
30 changed files with 988 additions and 46 deletions
@@ -92,6 +92,7 @@ public final class AdminProgressionService {
long deepslate = player.deepslateBroken();
long obsidian = player.obsidianBroken();
long placements = player.blocksPlacedInBase();
long totalPlacements = player.totalBlocksPlaced();
long baseBreaks = player.blocksBrokenInBase();
switch (counter) {
case GRASS_DIRT -> grassDirt = amount;
@@ -99,6 +100,7 @@ public final class AdminProgressionService {
case DEEPSLATE -> deepslate = amount;
case OBSIDIAN -> obsidian = amount;
case PLACEMENTS -> placements = amount;
case TOTAL_PLACEMENTS -> totalPlacements = amount;
case BASE_BREAKS -> baseBreaks = amount;
}
PluginSettings configured = settings.current();
@@ -147,7 +149,11 @@ public final class AdminProgressionService {
}
PlayerState updated = player.withProgressCounters(
grassDirt, stone, deepslate, obsidian, placements, baseBreaks
);
).withTotalBlocksPlaced(totalPlacements);
if (totalPlacements < configured.spawnableOverlayUnlockPlacements()
&& updated.spawnableOverlayEnabled()) {
updated = updated.withSpawnableOverlayEnabled(false);
}
return updated.withAdministrativeLevels(
base,
size,
@@ -16,14 +16,17 @@ import org.bukkit.plugin.java.JavaPlugin;
final class BaseAdminCommand implements CommandExecutor, TabCompleter {
private static final List<String> SUBCOMMANDS = List.of(
"status", "setlevel", "setprogress", "clearcooldown", "reset", "config"
"status", "setlevel", "setprogress", "setsetting", "clearcooldown", "reset", "config"
);
private static final List<String> PATHS = List.of(
"base", "size", "flight", "warmup", "cooldown"
);
private static final List<String> COUNTERS = List.of(
"grass_dirt", "stone", "deepslate", "obsidian", "placements", "base_breaks"
"grass_dirt", "stone", "deepslate", "obsidian", "placements", "total_placements",
"base_breaks"
);
private static final List<String> SETTINGS = List.of("spawnable");
private static final List<String> ENABLE_MODES = List.of("enable", "disable");
private static final List<String> COOLDOWN_TYPES = List.of("personal", "visitor", "all");
private static final List<String> RESET_PATHS = List.of(
"size", "flight", "warmup", "cooldown", "all"
@@ -69,6 +72,7 @@ final class BaseAdminCommand implements CommandExecutor, TabCompleter {
case "status" -> showProgress(sender, target.orElseThrow(), arguments);
case "setlevel" -> setLevel(sender, target.orElseThrow(), arguments);
case "setprogress" -> setProgress(sender, target.orElseThrow(), arguments);
case "setsetting" -> setSetting(sender, target.orElseThrow(), arguments);
case "clearcooldown" -> clearCooldown(sender, target.orElseThrow(), arguments);
case "reset" -> reset(sender, target.orElseThrow(), arguments);
default -> {
@@ -94,11 +98,17 @@ final class BaseAdminCommand implements CommandExecutor, TabCompleter {
sender.sendMessage(ChatColor.GRAY + "Grass/dirt=" + player.grassAndDirtBroken()
+ " stone=" + player.stoneBroken() + " deepslate=" + player.deepslateBroken()
+ " obsidian=" + player.obsidianBroken());
int spawnableThreshold = settingsProvider.current()
.spawnableOverlayUnlockPlacements();
sender.sendMessage(ChatColor.GRAY + "In-base placements=" + player.blocksPlacedInBase()
+ " total placements=" + player.totalBlocksPlaced()
+ " breaks=" + player.blocksBrokenInBase());
sender.sendMessage(ChatColor.GRAY + "Spawnable overlay="
+ (player.totalBlocksPlaced() >= spawnableThreshold ? "unlocked" : "locked")
+ " (" + player.totalBlocksPlaced() + "/" + spawnableThreshold + ")");
sender.sendMessage(ChatColor.GRAY + "Toggles: navigation=" + player.navigationEnabled()
+ " flight=" + player.flightEnabled() + " border=" + player.borderEnabled()
+ " bossbar=" + player.bossBarEnabled() + " visitors=" + player.visitorsEnabled());
+ " spawnable=" + player.spawnableOverlayEnabled() + " bossbar=" + player.bossBarEnabled() + " visitors=" + player.visitorsEnabled());
sender.sendMessage(ChatColor.GRAY + "Base: " + player.base()
.map(base -> base.worldName() + " " + base.x() + "," + base.y() + "," + base.z())
.orElse("not set"));
@@ -160,7 +170,8 @@ final class BaseAdminCommand implements CommandExecutor, TabCompleter {
} catch (IllegalArgumentException exception) {
sender.sendMessage(ChatColor.RED
+ "Usage: /baseadmin setprogress <player> "
+ "<grass_dirt|stone|deepslate|obsidian|placements|base_breaks> <amount>");
+ "<grass_dirt|stone|deepslate|obsidian|placements|total_placements|base_breaks> "
+ "<amount>");
return true;
}
try {
@@ -181,6 +192,39 @@ final class BaseAdminCommand implements CommandExecutor, TabCompleter {
return true;
}
private boolean setSetting(
CommandSender sender,
PlayerState target,
String[] arguments
) {
if (arguments.length != 4
|| !arguments[2].equalsIgnoreCase("spawnable")
|| !ENABLE_MODES.contains(arguments[3].toLowerCase(Locale.ROOT))) {
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin setsetting <player> "
+ "spawnable <enable|disable>");
return true;
}
boolean enabled = arguments[3].equalsIgnoreCase("enable");
if (enabled && target.totalBlocksPlaced()
< settingsProvider.current().spawnableOverlayUnlockPlacements()) {
sender.sendMessage(ChatColor.RED + "That player has not unlocked the spawnable overlay.");
return true;
}
if (enabled && (target.baseLevel() < 1 || target.base().isEmpty())) {
sender.sendMessage(ChatColor.RED + "That player does not have an established base.");
return true;
}
PlayerState updated = stateManager.update(
target.playerId(),
target.latestName(),
current -> current.withSpawnableOverlayEnabled(enabled)
);
stateManager.saveIfDirty();
sender.sendMessage(ChatColor.GREEN + "Set " + updated.latestName()
+ "'s spawnable overlay to " + (enabled ? "enabled" : "disabled") + ".");
return true;
}
private boolean clearCooldown(
CommandSender sender,
PlayerState target,
@@ -350,12 +394,16 @@ final class BaseAdminCommand implements CommandExecutor, TabCompleter {
List<String> options = switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "setlevel" -> PATHS;
case "setprogress" -> COUNTERS;
case "setsetting" -> SETTINGS;
case "clearcooldown" -> COOLDOWN_TYPES;
case "reset" -> RESET_PATHS;
default -> List.of();
};
return complete(options, arguments[2]);
}
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("setsetting")) {
return complete(ENABLE_MODES, arguments[3]);
}
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("setlevel")) {
List<String> levels = switch (arguments[2].toLowerCase(Locale.ROOT)) {
case "base", "cooldown" -> LEVELS_FOUR;
@@ -386,7 +434,7 @@ final class BaseAdminCommand implements CommandExecutor, TabCompleter {
private static void sendUsage(CommandSender sender) {
sender.sendMessage(ChatColor.YELLOW + "Usage: /baseadmin "
+ "<status|setlevel|setprogress|clearcooldown|reset> <player> ...");
+ "<status|setlevel|setprogress|setsetting|clearcooldown|reset> <player> ...");
sender.sendMessage(ChatColor.YELLOW + " /baseadmin config <key> <integer>");
}
}
@@ -24,6 +24,7 @@ final class BaseProgressListener implements Listener {
private final BaseProgressionService baseProgressionService;
private final SecondaryProgressionService secondaryProgressionService;
private final TeleportProgressionService teleportProgressionService;
private final SpawnableOverlayProgressionService spawnableOverlayProgressionService;
private final BaseBoundsService boundsService;
private final PluginSettingsProvider settings;
private final Map<UUID, BossBar> activeBossBars = new HashMap<>();
@@ -34,6 +35,7 @@ final class BaseProgressListener implements Listener {
BaseProgressionService baseProgressionService,
SecondaryProgressionService secondaryProgressionService,
TeleportProgressionService teleportProgressionService,
SpawnableOverlayProgressionService spawnableOverlayProgressionService,
BaseBoundsService boundsService,
PluginSettingsProvider settings
) {
@@ -42,6 +44,7 @@ final class BaseProgressListener implements Listener {
this.baseProgressionService = baseProgressionService;
this.secondaryProgressionService = secondaryProgressionService;
this.teleportProgressionService = teleportProgressionService;
this.spawnableOverlayProgressionService = spawnableOverlayProgressionService;
this.boundsService = boundsService;
this.settings = settings;
}
@@ -97,26 +100,43 @@ final class BaseProgressListener implements Listener {
return;
}
PlayerState before = stateManager.player(player.getUniqueId(), player.getName());
if (!isInsideBase(
before,
event.getBlockPlaced().getWorld().getUID(),
event.getBlockPlaced().getX(),
event.getBlockPlaced().getY(),
event.getBlockPlaced().getZ())) {
return;
}
boolean insideBase = isInsideBase(
before,
event.getBlockPlaced().getWorld().getUID(),
event.getBlockPlaced().getX(),
event.getBlockPlaced().getY(),
event.getBlockPlaced().getZ()
);
ProgressionUpdate[] updateHolder = new ProgressionUpdate[1];
SpawnableOverlayProgressionUpdate[] overlayHolder =
new SpawnableOverlayProgressionUpdate[1];
PlayerState state = stateManager.update(player.getUniqueId(), player.getName(), current -> {
ProgressionUpdate update = teleportProgressionService.recordPlacement(current);
SpawnableOverlayProgressionUpdate overlay =
spawnableOverlayProgressionService.recordPlacement(current);
ProgressionUpdate update = insideBase
? teleportProgressionService.recordPlacement(overlay.player())
: ProgressionUpdate.unchanged(overlay.player());
overlayHolder[0] = overlay;
updateHolder[0] = update;
return update.player();
});
ProgressionUpdate update = updateHolder[0];
SpawnableOverlayProgressionUpdate overlay = overlayHolder[0];
announceUnlock(player, update);
if (hasUnlock(update)) {
if (overlay.unlocked()) {
sendUnlockTitle(
player,
ChatColor.GOLD + "Spawnable Overlay Unlocked",
ChatColor.YELLOW + "/basesettings spawnable enable is now available"
);
player.sendMessage(ChatColor.GREEN
+ "You unlocked spawnable block highlighting! Use "
+ "/basesettings spawnable enable.");
}
if (hasUnlock(update) || overlay.unlocked()) {
stateManager.saveIfDirty();
}
if (state.bossBarEnabled() && state.baseLevel() >= 2) {
if (insideBase && state.bossBarEnabled() && state.baseLevel() >= 2) {
showProgress(player, warmupDisplay(state));
}
}
@@ -15,7 +15,8 @@ import org.bukkit.inventory.PlayerInventory;
final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private static final List<String> SETTINGS = List.of(
"status", "upgrade", "visitors", "navigation", "flight", "border", "bossbar"
"status", "upgrade", "visitors", "navigation", "flight", "border", "spawnable",
"bossbar"
);
private static final List<String> VISITOR_MODES = List.of("allowed", "blocked");
private static final List<String> ENABLE_MODES = List.of("enable", "disable");
@@ -62,6 +63,9 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("border")) {
return updateBorder(player, state, arguments[1]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("spawnable")) {
return updateSpawnableOverlay(player, state, arguments[1]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("bossbar")) {
return updateBossBar(player, arguments[1]);
}
@@ -215,6 +219,36 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
return true;
}
private boolean updateSpawnableOverlay(Player player, PlayerState state, String mode) {
if (state.totalBlocksPlaced() < settings.current().spawnableOverlayUnlockPlacements()) {
player.sendMessage(ChatColor.RED + "Spawnable highlighting unlocks after "
+ settings.current().spawnableOverlayUnlockPlacements() + " total placements.");
return true;
}
if (state.baseLevel() < 1 || state.base().isEmpty()) {
player.sendMessage(ChatColor.RED
+ "Establish Base I before enabling spawnable highlighting.");
return true;
}
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withSpawnableOverlayEnabled(enabled)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Spawnable block highlighting is now "
+ (state.spawnableOverlayEnabled()
? ChatColor.GREEN + "enabled"
: ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
return true;
}
private boolean updateBossBar(Player player, String mode) {
Boolean enabled = enabledMode(mode);
if (enabled == null) {
@@ -240,11 +274,17 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
showFlightPath(player, state);
showWarmupPath(player, state);
showCooldownPath(player, state);
int spawnableThreshold = settings.current().spawnableOverlayUnlockPlacements();
player.sendMessage(ChatColor.YELLOW + "Spawnable Overlay: " + ChatColor.GRAY
+ state.totalBlocksPlaced() + "/" + spawnableThreshold + " placements; "
+ (state.totalBlocksPlaced() >= spawnableThreshold ? "unlocked" : "locked")
+ "; setting=" + (state.spawnableOverlayEnabled() ? "enabled" : "disabled"));
player.sendMessage(ChatColor.GRAY + "Settings: visitors="
+ (state.visitorsEnabled() ? "allowed" : "blocked")
+ " navigation=" + (state.navigationEnabled() ? "enabled" : "disabled")
+ " flight=" + (state.flightEnabled() ? "enabled" : "disabled")
+ " border=" + (state.borderEnabled() ? "enabled" : "disabled")
+ " spawnable=" + (state.spawnableOverlayEnabled() ? "enabled" : "disabled")
+ " bossbar=" + (state.bossBarEnabled() ? "enabled" : "disabled"));
state.base().ifPresentOrElse(
base -> player.sendMessage(ChatColor.GRAY + "Base: " + base.worldName() + " "
@@ -333,7 +373,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
List<String> modes = arguments[0].equalsIgnoreCase("visitors")
? VISITOR_MODES
: switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "navigation", "flight", "border", "bossbar" -> ENABLE_MODES;
case "navigation", "flight", "border", "spawnable", "bossbar" -> ENABLE_MODES;
default -> List.of();
};
String prefix = arguments[1].toLowerCase(Locale.ROOT);
@@ -399,6 +439,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private static void sendUsage(Player player) {
player.sendMessage(ChatColor.RED + "Usage: /basesettings "
+ "[status|upgrade|visitors <allowed|blocked>|navigation <enable|disable>"
+ "|flight <enable|disable>|border <enable|disable>|bossbar <enable|disable>]");
+ "|flight <enable|disable>|border <enable|disable>"
+ "|spawnable <enable|disable>|bossbar <enable|disable>]");
}
}
@@ -27,7 +27,9 @@ public record PlayerState(
Optional<Instant> lastBaseSet,
Optional<Instant> lastBaseTeleport,
Map<UUID, Instant> visitorCooldownUntil,
boolean borderEnabled
boolean borderEnabled,
long totalBlocksPlaced,
boolean spawnableOverlayEnabled
) {
public PlayerState {
if (playerId == null) {
@@ -54,6 +56,7 @@ public record PlayerState(
requireNonNegative(obsidianBroken, "obsidian broken");
requireNonNegative(blocksPlacedInBase, "blocks placed in base");
requireNonNegative(blocksBrokenInBase, "blocks broken in base");
requireNonNegative(totalBlocksPlaced, "total blocks placed");
if (baseLevel == 0 && (sizeLevel > 0 || flightLevel > 0)) {
throw new IllegalArgumentException("secondary progression requires Base I");
@@ -73,6 +76,9 @@ public record PlayerState(
if (borderEnabled && (baseLevel < 1 || base.isEmpty())) {
throw new IllegalArgumentException("border visualization requires an established Base I");
}
if (spawnableOverlayEnabled && (baseLevel < 1 || base.isEmpty())) {
throw new IllegalArgumentException("spawnable overlay requires an established Base I");
}
if (visitorCooldownUntil.entrySet().stream().anyMatch(entry ->
entry.getKey() == null || entry.getValue() == null)) {
throw new IllegalArgumentException("visitor cooldowns must be complete");
@@ -107,7 +113,40 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, false
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, false, 0, false
);
}
public PlayerState(
UUID playerId,
String latestName,
Optional<BaseLocation> base,
int baseLevel,
int sizeLevel,
int flightLevel,
int warmupLevel,
int cooldownLevel,
long grassAndDirtBroken,
long stoneBroken,
long deepslateBroken,
long obsidianBroken,
long blocksPlacedInBase,
long blocksBrokenInBase,
boolean navigationEnabled,
boolean flightEnabled,
boolean bossBarEnabled,
boolean visitorsEnabled,
Optional<Instant> lastBaseSet,
Optional<Instant> lastBaseTeleport,
Map<UUID, Instant> visitorCooldownUntil,
boolean borderEnabled
) {
this(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled, 0, false
);
}
@@ -129,7 +168,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -139,7 +179,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, count, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -149,7 +190,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, enabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -159,7 +201,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
enabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -169,7 +212,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stone, deepslate,
obsidian, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -179,7 +223,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, enabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -199,7 +244,8 @@ public record PlayerState(
newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, placements, breaks,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -215,7 +261,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, Optional.of(usedAt), visitorCooldownUntil, borderEnabled
lastBaseSet, Optional.of(usedAt), visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -225,7 +272,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -242,7 +290,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassDirt, stone, deepslate,
obsidian, placements, baseBreaks,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -262,7 +311,9 @@ public record PlayerState(
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
newNavigationEnabled, newFlightEnabled, bossBarEnabled, newVisitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil,
borderEnabled && newBaseLevel >= 1 && base.isPresent()
borderEnabled && newBaseLevel >= 1 && base.isPresent(),
totalBlocksPlaced,
spawnableOverlayEnabled && newBaseLevel >= 1 && base.isPresent()
);
}
@@ -272,7 +323,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, enabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -282,7 +334,30 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, enabled
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, enabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
public PlayerState withTotalBlocksPlaced(long count) {
return new PlayerState(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
count, spawnableOverlayEnabled
);
}
public PlayerState withSpawnableOverlayEnabled(boolean enabled) {
return new PlayerState(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, enabled
);
}
@@ -294,7 +369,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, cooldowns, borderEnabled
lastBaseSet, lastBaseTeleport, cooldowns, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -304,7 +380,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, Optional.empty(), visitorCooldownUntil, borderEnabled
lastBaseSet, Optional.empty(), visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -314,7 +391,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, Map.of(), borderEnabled
lastBaseSet, lastBaseTeleport, Map.of(), borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -324,7 +402,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
Optional.of(setAt), lastBaseTeleport, visitorCooldownUntil, borderEnabled
Optional.of(setAt), lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -21,6 +21,7 @@ public record PluginSettings(
int flightWarningBuffer,
int secondFlightVerticalRange,
int teleportUnlockPlacements,
int spawnableOverlayUnlockPlacements,
int secondWarmupPlacements,
int thirdWarmupPlacements,
int instantWarmupPlacements,
@@ -62,6 +63,7 @@ public record PluginSettings(
private static final int DEFAULT_FLIGHT_WARNING_BUFFER = 5;
private static final int DEFAULT_SECOND_FLIGHT_VERTICAL_RANGE = 100;
private static final int DEFAULT_TELEPORT_UNLOCK_PLACEMENTS = 200;
private static final int DEFAULT_SPAWNABLE_OVERLAY_UNLOCK_PLACEMENTS = 250;
private static final int DEFAULT_SECOND_WARMUP_PLACEMENTS = 1_000;
private static final int DEFAULT_THIRD_WARMUP_PLACEMENTS = 2_000;
private static final int DEFAULT_INSTANT_WARMUP_PLACEMENTS = 12_000;
@@ -110,6 +112,7 @@ public record PluginSettings(
throw new IllegalArgumentException("second-flight-vertical-range must exceed the initial range");
}
requirePositive(teleportUnlockPlacements, "teleport-unlock-placements");
requirePositive(spawnableOverlayUnlockPlacements, "spawnable-overlay-unlock-placements");
if (secondWarmupPlacements <= teleportUnlockPlacements
|| thirdWarmupPlacements <= secondWarmupPlacements
|| instantWarmupPlacements <= thirdWarmupPlacements) {
@@ -175,6 +178,11 @@ public record PluginSettings(
integer(values, "flight-warning-buffer", DEFAULT_FLIGHT_WARNING_BUFFER),
integer(values, "second-flight-vertical-range", DEFAULT_SECOND_FLIGHT_VERTICAL_RANGE),
integer(values, "teleport-unlock-placements", DEFAULT_TELEPORT_UNLOCK_PLACEMENTS),
integer(
values,
"spawnable-overlay-unlock-placements",
DEFAULT_SPAWNABLE_OVERLAY_UNLOCK_PLACEMENTS
),
integer(values, "second-warmup-placements", DEFAULT_SECOND_WARMUP_PLACEMENTS),
integer(values, "third-warmup-placements", DEFAULT_THIRD_WARMUP_PLACEMENTS),
integer(values, "instant-warmup-placements", DEFAULT_INSTANT_WARMUP_PLACEMENTS),
@@ -6,5 +6,6 @@ public enum ProgressCounter {
DEEPSLATE,
OBSIDIAN,
PLACEMENTS,
TOTAL_PLACEMENTS,
BASE_BREAKS
}
@@ -0,0 +1,38 @@
package games.dmg.spigotbase;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.Bisected;
import org.bukkit.block.data.type.Slab;
import org.bukkit.block.data.type.Stairs;
final class SpawnableBlockPolicy {
private SpawnableBlockPolicy() {
}
static boolean isCandidate(Block surface) {
if (!hasSpawnableTop(surface)) {
return false;
}
Block above = surface.getRelative(BlockFace.UP);
Block secondAbove = above.getRelative(BlockFace.UP);
return isEmpty(above)
&& isEmpty(secondAbove)
&& above.getLightFromBlocks() == 0
&& above.getLightFromSky() <= 7;
}
private static boolean hasSpawnableTop(Block block) {
if (block.getBlockData() instanceof Slab slab) {
return slab.getType() == Slab.Type.TOP || slab.getType() == Slab.Type.DOUBLE;
}
if (block.getBlockData() instanceof Stairs stairs) {
return stairs.getHalf() == Bisected.Half.TOP;
}
return block.getType().isOccluding();
}
private static boolean isEmpty(Block block) {
return block.isPassable() && !block.isLiquid();
}
}
@@ -0,0 +1,185 @@
package games.dmg.spigotbase;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
final class SpawnableOverlayController implements Runnable {
static final int HORIZONTAL_RADIUS = 16;
static final int VERTICAL_RADIUS = 8;
static final int WORK_BUDGET = 2_048;
static final int PARTICLE_BUDGET = 128;
private static final int PENDING_BUDGET = PARTICLE_BUDGET * 4;
private static final int WIDTH = HORIZONTAL_RADIUS * 2 + 1;
private static final int HEIGHT = VERTICAL_RADIUS * 2 + 1;
private static final int TOTAL_POSITIONS = WIDTH * WIDTH * HEIGHT;
private static final Particle.DustOptions RED_DUST =
new Particle.DustOptions(Color.RED, 1.0F);
private final Server server;
private final BaseStateManager stateManager;
private final BaseBoundsService boundsService;
private final PluginSettingsProvider settings;
private final Map<UUID, ScanState> scans = new HashMap<>();
SpawnableOverlayController(
Server server,
BaseStateManager stateManager,
BaseBoundsService boundsService,
PluginSettingsProvider settings
) {
this.server = server;
this.stateManager = stateManager;
this.boundsService = boundsService;
this.settings = settings;
}
@Override
public void run() {
Set<UUID> active = new HashSet<>();
for (Player player : server.getOnlinePlayers()) {
PlayerState playerState = stateManager.player(
player.getUniqueId(), player.getName()
);
if (!eligible(player, playerState)) {
scans.remove(player.getUniqueId());
continue;
}
active.add(player.getUniqueId());
updateAndRender(player, playerState);
}
scans.keySet().removeIf(playerId -> !active.contains(playerId));
}
private boolean eligible(Player player, PlayerState state) {
return state.spawnableOverlayEnabled()
&& state.totalBlocksPlaced() >= settings.current()
.spawnableOverlayUnlockPlacements()
&& state.base().isPresent()
&& player.getWorld().getUID().equals(state.base().orElseThrow().worldId());
}
private void updateAndRender(Player player, PlayerState state) {
Location location = player.getLocation();
BaseLocation base = state.base().orElseThrow();
int centerX = location.getBlockX();
int centerY = location.getBlockY() - 1;
int centerZ = location.getBlockZ();
ScanOrigin origin = new ScanOrigin(
player.getWorld().getUID(), centerX, centerY, centerZ,
base.x(), base.y(), base.z(), boundsService.radius(state)
);
ScanState scan = scans.get(player.getUniqueId());
if (scan == null || !scan.origin.near(origin)) {
scan = new ScanState(origin);
scans.put(player.getUniqueId(), scan);
}
scan(player.getWorld(), boundsService.area(state), scan);
render(player, scan);
}
private static void scan(World world, BaseArea base, ScanState scan) {
int work = Math.min(WORK_BUDGET, TOTAL_POSITIONS);
int minimumWorldY = world.getMinHeight();
int maximumWorldY = world.getMaxHeight();
UUID worldId = world.getUID();
for (int offset = 0; offset < work; offset++) {
int index = (scan.cursor + offset) % TOTAL_POSITIONS;
int verticalIndex = index % HEIGHT;
int horizontalIndex = index / HEIGHT;
int zIndex = horizontalIndex % WIDTH;
int xIndex = horizontalIndex / WIDTH;
int x = scan.origin.centerX + xIndex - HORIZONTAL_RADIUS;
int y = scan.origin.centerY + verticalIndex - VERTICAL_RADIUS;
int z = scan.origin.centerZ + zIndex - HORIZONTAL_RADIUS;
BlockPosition position = new BlockPosition(x, y, z);
scan.spawnable.remove(position);
int deltaX = x - scan.origin.centerX;
int deltaZ = z - scan.origin.centerZ;
if (deltaX * deltaX + deltaZ * deltaZ
> HORIZONTAL_RADIUS * HORIZONTAL_RADIUS
|| y < minimumWorldY
|| y >= maximumWorldY
|| !base.contains(worldId, x, y, z)) {
continue;
}
Block surface = world.getBlockAt(x, y, z);
if (SpawnableBlockPolicy.isCandidate(surface)) {
scan.spawnable.add(position);
if (scan.pending.size() < PENDING_BUDGET) {
scan.pending.addLast(position);
}
}
}
scan.cursor = (scan.cursor + work) % TOTAL_POSITIONS;
}
private static void render(Player player, ScanState scan) {
for (int attempted = 0; attempted < PARTICLE_BUDGET; attempted++) {
BlockPosition position = scan.pending.pollFirst();
if (position == null) {
return;
}
if (!scan.spawnable.contains(position)) {
continue;
}
player.spawnParticle(
Particle.DUST,
new Location(
player.getWorld(),
position.x + 0.5,
position.y + 1.05,
position.z + 0.5
),
1,
RED_DUST
);
}
}
private record BlockPosition(int x, int y, int z) {
}
private record ScanOrigin(
UUID worldId,
int centerX,
int centerY,
int centerZ,
int baseX,
int baseY,
int baseZ,
int baseRadius
) {
boolean near(ScanOrigin other) {
return worldId.equals(other.worldId)
&& Math.abs(centerX - other.centerX) <= 4
&& Math.abs(centerY - other.centerY) <= 4
&& Math.abs(centerZ - other.centerZ) <= 4
&& baseX == other.baseX
&& baseY == other.baseY
&& baseZ == other.baseZ
&& baseRadius == other.baseRadius;
}
}
private static final class ScanState {
private final ScanOrigin origin;
private final Set<BlockPosition> spawnable = new HashSet<>();
private final ArrayDeque<BlockPosition> pending = new ArrayDeque<>();
private int cursor;
private ScanState(ScanOrigin origin) {
this.origin = origin;
}
}
}
@@ -0,0 +1,19 @@
package games.dmg.spigotbase;
final class SpawnableOverlayProgressionService {
private final PluginSettingsProvider settings;
SpawnableOverlayProgressionService(PluginSettingsProvider settings) {
this.settings = settings;
}
SpawnableOverlayProgressionUpdate recordPlacement(PlayerState player) {
long previous = player.totalBlocksPlaced();
long updated = previous == Long.MAX_VALUE ? Long.MAX_VALUE : previous + 1;
int threshold = settings.current().spawnableOverlayUnlockPlacements();
return new SpawnableOverlayProgressionUpdate(
player.withTotalBlocksPlaced(updated),
previous < threshold && updated >= threshold
);
}
}
@@ -0,0 +1,9 @@
package games.dmg.spigotbase;
record SpawnableOverlayProgressionUpdate(PlayerState player, boolean unlocked) {
SpawnableOverlayProgressionUpdate {
if (player == null) {
throw new IllegalArgumentException("player is required");
}
}
}
@@ -46,6 +46,7 @@ public final class SpigotBasePlugin extends JavaPlugin {
progressionService,
secondaryProgressionService,
teleportProgressionService,
new SpawnableOverlayProgressionService(settingsProvider),
boundsService,
settingsProvider
);
@@ -97,6 +98,14 @@ public final class SpigotBasePlugin extends JavaPlugin {
10L,
10L
);
getServer().getScheduler().runTaskTimer(
this,
new SpawnableOverlayController(
getServer(), stateManager, boundsService, settingsProvider
),
10L,
10L
);
getServer().getScheduler().runTaskTimer(this, flightController, 5L, 5L);
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
getLogger().info("Spigot Base enabled.");
@@ -128,7 +128,9 @@ public final class YamlBaseStateRepository {
instant(yaml, path + ".last-base-set-epoch-millis"),
instant(yaml, path + ".last-base-teleport-epoch-millis"),
loadVisitorCooldowns(yaml, path + ".visitor-cooldowns"),
yaml.getBoolean(path + ".border-enabled", false)
yaml.getBoolean(path + ".border-enabled", false),
totalPlacementCount(yaml, path),
yaml.getBoolean(path + ".spawnable-overlay-enabled", false)
);
players.put(playerId, player);
} catch (IllegalArgumentException ignored) {
@@ -195,6 +197,13 @@ public final class YamlBaseStateRepository {
return yaml.getLong(path);
}
private static long totalPlacementCount(YamlConfiguration yaml, String playerPath) {
return Math.max(
count(yaml, playerPath + ".total-blocks-placed"),
count(yaml, playerPath + ".blocks-placed-in-base")
);
}
private static Optional<Instant> instant(YamlConfiguration yaml, String path) {
if (!yaml.isLong(path) && !yaml.isInt(path)) {
return Optional.empty();
@@ -224,6 +233,8 @@ public final class YamlBaseStateRepository {
yaml.set(path + ".boss-bar-enabled", player.bossBarEnabled());
yaml.set(path + ".visitors-enabled", player.visitorsEnabled());
yaml.set(path + ".border-enabled", player.borderEnabled());
yaml.set(path + ".total-blocks-placed", player.totalBlocksPlaced());
yaml.set(path + ".spawnable-overlay-enabled", player.spawnableOverlayEnabled());
yaml.set(
path + ".last-base-set-epoch-millis",
player.lastBaseSet().map(Instant::toEpochMilli).orElse(null)
+3
View File
@@ -46,6 +46,9 @@ fourth-teleport-cooldown-seconds: 1800
visitor-unlock-diamond-cost: 128
visitor-currency-material: DIAMOND
# Spawnable block overlay
spawnable-overlay-unlock-placements: 250
# Progress and notification presentation
cooldown-excluded-materials: []
boss-bar-duration-ticks: 60
+1 -1
View File
@@ -19,7 +19,7 @@ commands:
aliases: [visit]
basesettings:
description: View progression, upgrade, and manage base settings.
usage: /basesettings [status|upgrade|visitors|navigation|flight|border|bossbar]
usage: /basesettings [status|upgrade|visitors|navigation|flight|border|spawnable|bossbar]
aliases: [homesettings]
baseadmin:
description: Administer Spigot Base.
@@ -11,6 +11,19 @@ final class AdminProgressCounterTest {
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
@Test
void settingTotalPlacementCounterUnlocksSpawnableOverlay() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
PlayerState updated = service.setProgress(
player,
ProgressCounter.TOTAL_PLACEMENTS,
250
);
assertEquals(250, updated.totalBlocksPlaced());
}
@Test
void settingPlacementCounterEvaluatesBaseAndWarmupTiers() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
@@ -53,6 +53,24 @@ final class AdminProgressionServiceTest {
assertFalse(updated.borderEnabled());
}
@Test
void completeResetClearsSpawnableOverlayProgressAndPreference() {
UUID playerId = UUID.randomUUID();
PlayerState player = PlayerState.newPlayer(playerId, "Alex")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withTotalBlocksPlaced(250)
.withSpawnableOverlayEnabled(true);
PlayerState updated = service.resetPath(player, ProgressionPath.BASE);
assertEquals(0, updated.totalBlocksPlaced());
assertFalse(updated.spawnableOverlayEnabled());
}
@Test
void rejectsUnknownLevel() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
@@ -1,6 +1,7 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
@@ -9,10 +10,13 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.command.CommandSender;
@@ -22,6 +26,46 @@ import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
final class BaseAdminCommandTest {
@Test
void adminCanEnableUnlockedSpawnableOverlayForPlayer() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
UUID playerId = UUID.randomUUID();
PlayerState target = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withTotalBlocksPlaced(250);
AtomicReference<PlayerState> updated = new AtomicReference<>();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.findByName("Builder")).thenReturn(Optional.of(target));
when(stateManager.update(any(), anyString(), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
PlayerState result = operation.apply(target);
updated.set(result);
return result;
});
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
stateManager,
new AdminProgressionService(
new PluginSettingsProvider(PluginSettings.from(Map.of()))
),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null);
command.onCommand(sender, null, "baseadmin",
new String[] {"setsetting", "Builder", "spawnable", "enable"});
}
assertTrue(updated.get().spawnableOverlayEnabled());
}
@Test
void invalidPathShowsFriendlyUsage() {
CommandSender sender = mock(CommandSender.class);
@@ -92,10 +136,23 @@ final class BaseAdminCommandTest {
new String[] {"setlevel", "Builder", ""})
);
assertEquals(
List.of("grass_dirt", "stone", "deepslate", "obsidian", "placements", "base_breaks"),
List.of(
"grass_dirt", "stone", "deepslate", "obsidian", "placements",
"total_placements", "base_breaks"
),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setprogress", "Builder", ""})
);
assertEquals(
List.of("spawnable"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setsetting", "Builder", ""})
);
assertEquals(
List.of("enable"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setsetting", "Builder", "spawnable", "e"})
);
assertEquals(
List.of("personal", "visitor", "all"),
command.onTabComplete(sender, null, "baseadmin",
@@ -108,6 +165,42 @@ final class BaseAdminCommandTest {
);
}
@Test
void adminCanChangeSpawnableOverlayUnlockThreshold() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
JavaPlugin plugin = mock(JavaPlugin.class);
FileConfiguration configuration = mock(FileConfiguration.class);
when(plugin.getConfig()).thenReturn(configuration);
when(configuration.get("spawnable-overlay-unlock-placements")).thenReturn(250);
when(configuration.getValues(false)).thenReturn(Map.of(
"spawnable-overlay-unlock-placements", 250
));
PluginSettingsProvider settings = new PluginSettingsProvider(
PluginSettings.from(Map.of())
);
BaseAdminCommand command = new BaseAdminCommand(
plugin,
mock(BaseStateManager.class),
mock(AdminProgressionService.class),
settings
);
try (MockedStatic<PluginSettingsValidator> validator =
mockStatic(PluginSettingsValidator.class)) {
validator.when(() -> PluginSettingsValidator.validateMaterials(
any(PluginSettings.class)
)).thenAnswer(invocation -> invocation.getArgument(0));
command.onCommand(sender, null, "baseadmin", new String[] {
"config", "spawnable-overlay-unlock-placements", "500"
});
}
verify(configuration).set("spawnable-overlay-unlock-placements", 500L);
verify(plugin).saveConfig();
assertEquals(500, settings.current().spawnableOverlayUnlockPlacements());
}
@Test
void autocompletesNumericConfigurationKeys() {
CommandSender sender = mock(CommandSender.class);
@@ -169,7 +262,10 @@ final class BaseAdminCommandTest {
);
assertEquals(
List.of("status", "setlevel", "setprogress", "clearcooldown", "reset", "config"),
List.of(
"status", "setlevel", "setprogress", "setsetting", "clearcooldown", "reset",
"config"
),
command.onTabComplete(sender, null, "baseadmin", new String[] {""})
);
}
@@ -0,0 +1,66 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator;
import org.bukkit.GameMode;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.plugin.Plugin;
import org.junit.jupiter.api.Test;
final class BaseProgressListenerTest {
@Test
void survivalPlacementOutsideBaseCountsTowardOverlayUnlock() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Builder");
AtomicReference<PlayerState> updated = new AtomicReference<>();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(current);
when(stateManager.update(any(), anyString(), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
PlayerState result = operation.apply(current);
updated.set(result);
return result;
});
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
World world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
Block block = mock(Block.class);
when(block.getWorld()).thenReturn(world);
BlockPlaceEvent event = mock(BlockPlaceEvent.class);
when(event.getPlayer()).thenReturn(player);
when(event.getBlockPlaced()).thenReturn(block);
PluginSettingsProvider settings = new PluginSettingsProvider(
PluginSettings.from(Map.of())
);
BaseProgressListener listener = new BaseProgressListener(
mock(Plugin.class),
stateManager,
mock(BaseProgressionService.class),
mock(SecondaryProgressionService.class),
mock(TeleportProgressionService.class),
new SpawnableOverlayProgressionService(settings),
new BaseBoundsService(settings),
settings
);
listener.onBlockPlace(event);
assertEquals(1, updated.get().totalBlocksPlaced());
assertEquals(0, updated.get().blocksPlacedInBase());
}
}
@@ -24,6 +24,22 @@ import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
final class BaseSettingsCommandTest {
@Test
void spawnableEnableIsIdempotentAfterUnlock() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withTotalBlocksPlaced(250);
CommandResult result = execute(current, "spawnable", "enable");
assertTrue(result.updated().spawnableOverlayEnabled());
}
@Test
void borderEnableIsIdempotent() {
UUID playerId = UUID.randomUUID();
@@ -73,7 +89,7 @@ final class BaseSettingsCommandTest {
List.of("allowed"),
command.onTabComplete(null, null, "basesettings", new String[] {"visitors", "a"})
);
for (String setting : List.of("navigation", "flight", "border", "bossbar")) {
for (String setting : List.of("navigation", "flight", "border", "spawnable", "bossbar")) {
assertEquals(
List.of("disable"),
command.onTabComplete(null, null, "basesettings", new String[] {setting, "d"})
@@ -90,7 +106,10 @@ final class BaseSettingsCommandTest {
);
assertEquals(
List.of("status", "upgrade", "visitors", "navigation", "flight", "border", "bossbar"),
List.of(
"status", "upgrade", "visitors", "navigation", "flight", "border",
"spawnable", "bossbar"
),
command.onTabComplete(null, null, "basesettings", new String[] {""})
);
}
@@ -204,12 +223,20 @@ final class BaseSettingsCommandTest {
command.onCommand(player, null, "basesettings", new String[] {"status"});
verify(player, times(2)).sendMessage(ChatColor.GOLD + "=== Base Progress ===");
verify(player, times(2)).sendMessage(
org.mockito.ArgumentMatchers.<String>argThat(message ->
message.contains("Spawnable Overlay:")
&& message.contains("0/250 placements")
&& message.contains("locked")
)
);
verify(player, times(2)).sendMessage(
org.mockito.ArgumentMatchers.<String>argThat(message ->
message.contains("Settings: visitors=")
&& message.contains("navigation=")
&& message.contains("flight=")
&& message.contains("border=")
&& message.contains("spawnable=")
&& message.contains("bossbar=")
)
);
@@ -11,6 +11,23 @@ import java.util.UUID;
import org.junit.jupiter.api.Test;
final class PlayerStateTest {
@Test
void spawnableOverlayProgressAndPreferenceCanBeUpdated() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
PlayerState updated = player
.withTotalBlocksPlaced(250)
.withSpawnableOverlayEnabled(true);
assertTrue(updated.spawnableOverlayEnabled());
assertTrue(updated.totalBlocksPlaced() == 250);
}
@Test
void borderVisualizationIsDisabledByDefaultAndCanBeEnabled() {
UUID playerId = UUID.randomUUID();
@@ -18,6 +18,7 @@ final class PluginSettingsTest {
assertEquals(86_400L, settings.relocationCooldownSeconds());
assertEquals(5, settings.flightWarningBuffer());
assertEquals(200, settings.teleportUnlockPlacements());
assertEquals(250, settings.spawnableOverlayUnlockPlacements());
assertEquals(10_800L, settings.initialTeleportCooldownSeconds());
assertEquals(128, settings.visitorUnlockDiamondCost());
}
@@ -0,0 +1,48 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.type.Slab;
import org.junit.jupiter.api.Test;
final class SpawnableBlockPolicyTest {
@Test
void acceptsDarkSolidSurfaceWithTwoPassableBlocksAbove() {
Block surface = mock(Block.class);
Block above = mock(Block.class);
Block secondAbove = mock(Block.class);
Slab slab = mock(Slab.class);
when(slab.getType()).thenReturn(Slab.Type.TOP);
when(surface.getBlockData()).thenReturn(slab);
when(surface.getRelative(BlockFace.UP)).thenReturn(above);
when(above.getRelative(BlockFace.UP)).thenReturn(secondAbove);
when(above.isPassable()).thenReturn(true);
when(secondAbove.isPassable()).thenReturn(true);
when(above.getLightFromBlocks()).thenReturn((byte) 0);
when(above.getLightFromSky()).thenReturn((byte) 0);
assertTrue(SpawnableBlockPolicy.isCandidate(surface));
}
@Test
void rejectsBlockLitSurface() {
Block surface = mock(Block.class);
Block above = mock(Block.class);
Block secondAbove = mock(Block.class);
Slab slab = mock(Slab.class);
when(slab.getType()).thenReturn(Slab.Type.TOP);
when(surface.getBlockData()).thenReturn(slab);
when(surface.getRelative(BlockFace.UP)).thenReturn(above);
when(above.getRelative(BlockFace.UP)).thenReturn(secondAbove);
when(above.isPassable()).thenReturn(true);
when(secondAbove.isPassable()).thenReturn(true);
when(above.getLightFromBlocks()).thenReturn((byte) 1);
assertFalse(SpawnableBlockPolicy.isCandidate(surface));
}
}
@@ -0,0 +1,85 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.type.Slab;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class SpawnableOverlayControllerTest {
@Test
void ownerReceivesBoundedRedParticlesForNearbyCandidates() {
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PlayerState state = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH)
.withTotalBlocksPlaced(250)
.withSpawnableOverlayEnabled(true);
Block surface = mock(Block.class);
Block above = mock(Block.class);
Block secondAbove = mock(Block.class);
Slab slab = mock(Slab.class);
when(slab.getType()).thenReturn(Slab.Type.TOP);
when(surface.getBlockData()).thenReturn(slab);
when(surface.getRelative(BlockFace.UP)).thenReturn(above);
when(above.getRelative(BlockFace.UP)).thenReturn(secondAbove);
when(above.isPassable()).thenReturn(true);
when(secondAbove.isPassable()).thenReturn(true);
when(above.getLightFromBlocks()).thenReturn((byte) 0);
when(above.getLightFromSky()).thenReturn((byte) 0);
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
when(world.getMinHeight()).thenReturn(-64);
when(world.getMaxHeight()).thenReturn(320);
when(world.getBlockAt(anyInt(), anyInt(), anyInt()))
.thenReturn(surface);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(new Location(world, 0.5, 65.0, 0.5));
Server server = mock(Server.class);
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(state);
PluginSettingsProvider settings = new PluginSettingsProvider(
PluginSettings.from(Map.of())
);
SpawnableOverlayController controller = new SpawnableOverlayController(
server,
stateManager,
new BaseBoundsService(settings),
settings
);
controller.run();
controller.run();
controller.run();
verify(player, atLeastOnce()).spawnParticle(
eq(Particle.DUST),
any(Location.class),
eq(1),
any(Particle.DustOptions.class)
);
}
}
@@ -0,0 +1,24 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class SpawnableOverlayProgressionServiceTest {
@Test
void twoHundredFiftiethPlacementUnlocksOverlay() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Builder")
.withTotalBlocksPlaced(249);
SpawnableOverlayProgressionService service = new SpawnableOverlayProgressionService(
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
SpawnableOverlayProgressionUpdate update = service.recordPlacement(player);
assertEquals(250, update.player().totalBlocksPlaced());
assertTrue(update.unlocked());
}
}
@@ -29,6 +29,8 @@ final class YamlBaseStateRepositoryTest {
Optional.of(Instant.ofEpochMilli(1_750_000_000_000L)),
Optional.of(Instant.ofEpochMilli(1_750_000_100_000L)),
Map.of(ownerId, Instant.ofEpochMilli(1_750_001_000_000L)),
true,
2_500,
true
);
PersistentState expected = new PersistentState(Map.of(playerId, player));
@@ -40,6 +42,26 @@ final class YamlBaseStateRepositoryTest {
assertEquals(expected, repository.load());
}
@Test
void olderStateUsesKnownInBasePlacementsAsTotalPlacementMinimum() throws Exception {
UUID playerId = UUID.randomUUID();
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, """
players:
%s:
name: Alex
blocks-placed-in-base: 225
""".formatted(playerId));
PlayerState player = new YamlBaseStateRepository(stateFile)
.load()
.players()
.get(playerId);
assertEquals(225, player.totalBlocksPlaced());
assertFalse(player.spawnableOverlayEnabled());
}
@Test
void olderStateWithoutBorderPreferenceDefaultsToDisabled() throws Exception {
UUID playerId = UUID.randomUUID();