From 259ea9410cf8ac53b46969543bcd8f4122e4b721 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Mon, 10 Aug 2026 22:46:02 -0400 Subject: [PATCH] feat(spawning): add spawnable block overlay --- README.md | 7 +- design/log.md | 8 + design/user-stories/index.md | 1 + .../us-014-highlight-spawnable-blocks.md | 34 ++++ .../spigotbase/AdminProgressionService.java | 8 +- .../dmg/spigotbase/BaseAdminCommand.java | 58 +++++- .../dmg/spigotbase/BaseProgressListener.java | 42 ++-- .../dmg/spigotbase/BaseSettingsCommand.java | 47 ++++- .../games/dmg/spigotbase/PlayerState.java | 117 +++++++++-- .../games/dmg/spigotbase/PluginSettings.java | 8 + .../games/dmg/spigotbase/ProgressCounter.java | 1 + .../dmg/spigotbase/SpawnableBlockPolicy.java | 38 ++++ .../SpawnableOverlayController.java | 185 ++++++++++++++++++ .../SpawnableOverlayProgressionService.java | 19 ++ .../SpawnableOverlayProgressionUpdate.java | 9 + .../dmg/spigotbase/SpigotBasePlugin.java | 9 + .../spigotbase/YamlBaseStateRepository.java | 13 +- src/main/resources/config.yml | 3 + src/main/resources/plugin.yml | 2 +- .../spigotbase/AdminProgressCounterTest.java | 13 ++ .../AdminProgressionServiceTest.java | 18 ++ .../dmg/spigotbase/BaseAdminCommandTest.java | 100 +++++++++- .../spigotbase/BaseProgressListenerTest.java | 66 +++++++ .../spigotbase/BaseSettingsCommandTest.java | 31 ++- .../games/dmg/spigotbase/PlayerStateTest.java | 17 ++ .../dmg/spigotbase/PluginSettingsTest.java | 1 + .../spigotbase/SpawnableBlockPolicyTest.java | 48 +++++ .../SpawnableOverlayControllerTest.java | 85 ++++++++ ...pawnableOverlayProgressionServiceTest.java | 24 +++ .../YamlBaseStateRepositoryTest.java | 22 +++ 30 files changed, 988 insertions(+), 46 deletions(-) create mode 100644 design/user-stories/us-014-highlight-spawnable-blocks.md create mode 100644 src/main/java/games/dmg/spigotbase/SpawnableBlockPolicy.java create mode 100644 src/main/java/games/dmg/spigotbase/SpawnableOverlayController.java create mode 100644 src/main/java/games/dmg/spigotbase/SpawnableOverlayProgressionService.java create mode 100644 src/main/java/games/dmg/spigotbase/SpawnableOverlayProgressionUpdate.java create mode 100644 src/test/java/games/dmg/spigotbase/BaseProgressListenerTest.java create mode 100644 src/test/java/games/dmg/spigotbase/SpawnableBlockPolicyTest.java create mode 100644 src/test/java/games/dmg/spigotbase/SpawnableOverlayControllerTest.java create mode 100644 src/test/java/games/dmg/spigotbase/SpawnableOverlayProgressionServiceTest.java diff --git a/README.md b/README.md index 25875b7..e5bd742 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,12 @@ The plugin JAR is written to `build/libs/`. /basesettings navigation /basesettings flight /basesettings border +/basesettings spawnable /basesettings bossbar ``` +After 250 Survival-mode block placements anywhere by default, the spawnable overlay can mark nearby dark hostile-mob spawning surfaces inside the player's base with owner-only red particles. The threshold is configurable. + `/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown. ## Administration @@ -44,8 +47,10 @@ The `spigotbase.admin` permission is granted to server operators by default. `/h ```text /baseadmin status /baseadmin setlevel -/baseadmin setprogress +/baseadmin setprogress +/baseadmin setsetting spawnable /baseadmin config +# e.g. /baseadmin config spawnable-overlay-unlock-placements 250 /baseadmin clearcooldown [personal|visitor|all] /baseadmin reset /baseadmin reset all confirm diff --git a/design/log.md b/design/log.md index 9993268..54563d1 100644 --- a/design/log.md +++ b/design/log.md @@ -76,3 +76,11 @@ description: Chronological record of material decisions affecting the Spigot Bas - Rendered a bounded nearby arc at the player's current height using the current base radius while respecting world and vertical bounds. - Added border preference reporting to player and administrative status output and safe defaults for existing state files. - Verified the feature with `./gradlew clean check jar`. + +## 2026-08-10 — Spawnable block overlay + +- Added persistent progression for Survival-mode block placements anywhere, with existing in-base placement counts used as a safe migration minimum. +- Added `/basesettings spawnable enable|disable` after a configurable 250-placement unlock and reported its progress and preference in player and admin status output. +- Added admin controls for total placement progress, the unlock threshold, and each unlocked player's overlay preference. +- Added owner-only red particles for nearby dark spawning surfaces inside base bounds, using bounded incremental scanning and rendering budgets. +- Verified the feature with `./gradlew clean check jar`. diff --git a/design/user-stories/index.md b/design/user-stories/index.md index cb1a3d6..a46f2fa 100644 --- a/design/user-stories/index.md +++ b/design/user-stories/index.md @@ -19,3 +19,4 @@ description: Catalog of user stories for the Spigot Base plugin. 11. [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md) 12. [US-012: Build and release the plugin](us-012-build-and-release-plugin.md) 13. [US-013: Visualize the base border](us-013-visualize-base-border.md) +14. [US-014: Highlight spawnable blocks](us-014-highlight-spawnable-blocks.md) diff --git a/design/user-stories/us-014-highlight-spawnable-blocks.md b/design/user-stories/us-014-highlight-spawnable-blocks.md new file mode 100644 index 0000000..1ab1670 --- /dev/null +++ b/design/user-stories/us-014-highlight-spawnable-blocks.md @@ -0,0 +1,34 @@ +--- +type: User Story +title: "US-014: Highlight spawnable blocks" +description: Unlock an owner-only red particle overlay for nearby hostile-mob spawnable blocks after 250 placements anywhere. +status: done +--- + +# US-014: Highlight spawnable blocks + +As a **player with an established base**, I want to highlight nearby blocks where hostile mobs can spawn so that I can find lighting gaps in my base. + +## Acceptance criteria + +- [x] Every block placed in Survival mode anywhere contributes to a persistent total-placement counter. +- [x] The overlay unlocks permanently at a configurable 250 total placements by default. +- [x] Existing players begin with at least their persisted in-base placement count because those placements are known to qualify. +- [x] `/basesettings spawnable enable` enables the unlocked overlay idempotently, and `/basesettings spawnable disable` disables it idempotently. +- [x] `spawnable`, `enable`, and `disable` are offered through contextual autocomplete. +- [x] Enabling requires both the placement unlock and an established base; the persisted preference defaults to disabled. +- [x] `/basesettings status` and `/baseadmin status` display placement progress, unlock state, and preference. +- [x] Administrators can change a player's total-placement counter through `/baseadmin setprogress`. +- [x] Administrators can persistently change the required placement threshold through `/baseadmin config spawnable-overlay-unlock-placements`. +- [x] Administrators can enable or disable an unlocked player's overlay through `/baseadmin setsetting spawnable `. +- [x] Owner-only red particles mark nearby candidate hostile-mob spawning surfaces inside the current base bounds. +- [x] Candidate surfaces have block light zero, a solid spawning surface, and two passable blocks above them. +- [x] Scanning is bounded to 16 horizontal blocks, eight vertical blocks, and a fixed per-update work and particle budget. +- [x] A complete administrative reset clears total-placement progress and disables the overlay. + +## Related + +- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md) +- [US-009: View progression and notifications](us-009-view-progression-and-notifications.md) +- [US-010: Administer player progression](us-010-administer-player-progression.md) +- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md) diff --git a/src/main/java/games/dmg/spigotbase/AdminProgressionService.java b/src/main/java/games/dmg/spigotbase/AdminProgressionService.java index 60a23ee..85bf9f6 100644 --- a/src/main/java/games/dmg/spigotbase/AdminProgressionService.java +++ b/src/main/java/games/dmg/spigotbase/AdminProgressionService.java @@ -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, diff --git a/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java b/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java index 452c6e9..2032f8a 100644 --- a/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java +++ b/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java @@ -16,14 +16,17 @@ import org.bukkit.plugin.java.JavaPlugin; final class BaseAdminCommand implements CommandExecutor, TabCompleter { private static final List SUBCOMMANDS = List.of( - "status", "setlevel", "setprogress", "clearcooldown", "reset", "config" + "status", "setlevel", "setprogress", "setsetting", "clearcooldown", "reset", "config" ); private static final List PATHS = List.of( "base", "size", "flight", "warmup", "cooldown" ); private static final List 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 SETTINGS = List.of("spawnable"); + private static final List ENABLE_MODES = List.of("enable", "disable"); private static final List COOLDOWN_TYPES = List.of("personal", "visitor", "all"); private static final List 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 " - + " "); + + " " + + ""); 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 " + + "spawnable "); + 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 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 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 " - + " ..."); + + " ..."); sender.sendMessage(ChatColor.YELLOW + " /baseadmin config "); } } diff --git a/src/main/java/games/dmg/spigotbase/BaseProgressListener.java b/src/main/java/games/dmg/spigotbase/BaseProgressListener.java index cf6155a..0e73b17 100644 --- a/src/main/java/games/dmg/spigotbase/BaseProgressListener.java +++ b/src/main/java/games/dmg/spigotbase/BaseProgressListener.java @@ -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 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)); } } diff --git a/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java b/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java index 7ad56f1..3a9bc64 100644 --- a/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java +++ b/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java @@ -15,7 +15,8 @@ import org.bukkit.inventory.PlayerInventory; final class BaseSettingsCommand implements CommandExecutor, TabCompleter { private static final List SETTINGS = List.of( - "status", "upgrade", "visitors", "navigation", "flight", "border", "bossbar" + "status", "upgrade", "visitors", "navigation", "flight", "border", "spawnable", + "bossbar" ); private static final List VISITOR_MODES = List.of("allowed", "blocked"); private static final List 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 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 |navigation " - + "|flight |border |bossbar ]"); + + "|flight |border " + + "|spawnable |bossbar ]"); } } diff --git a/src/main/java/games/dmg/spigotbase/PlayerState.java b/src/main/java/games/dmg/spigotbase/PlayerState.java index 2e1daa6..7c43244 100644 --- a/src/main/java/games/dmg/spigotbase/PlayerState.java +++ b/src/main/java/games/dmg/spigotbase/PlayerState.java @@ -27,7 +27,9 @@ public record PlayerState( Optional lastBaseSet, Optional lastBaseTeleport, Map 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 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 lastBaseSet, + Optional lastBaseTeleport, + Map 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 ); } diff --git a/src/main/java/games/dmg/spigotbase/PluginSettings.java b/src/main/java/games/dmg/spigotbase/PluginSettings.java index 3f3306f..351fa75 100644 --- a/src/main/java/games/dmg/spigotbase/PluginSettings.java +++ b/src/main/java/games/dmg/spigotbase/PluginSettings.java @@ -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), diff --git a/src/main/java/games/dmg/spigotbase/ProgressCounter.java b/src/main/java/games/dmg/spigotbase/ProgressCounter.java index 2a30556..fe0b847 100644 --- a/src/main/java/games/dmg/spigotbase/ProgressCounter.java +++ b/src/main/java/games/dmg/spigotbase/ProgressCounter.java @@ -6,5 +6,6 @@ public enum ProgressCounter { DEEPSLATE, OBSIDIAN, PLACEMENTS, + TOTAL_PLACEMENTS, BASE_BREAKS } diff --git a/src/main/java/games/dmg/spigotbase/SpawnableBlockPolicy.java b/src/main/java/games/dmg/spigotbase/SpawnableBlockPolicy.java new file mode 100644 index 0000000..4e53e7c --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/SpawnableBlockPolicy.java @@ -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(); + } +} diff --git a/src/main/java/games/dmg/spigotbase/SpawnableOverlayController.java b/src/main/java/games/dmg/spigotbase/SpawnableOverlayController.java new file mode 100644 index 0000000..68c437d --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/SpawnableOverlayController.java @@ -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 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 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 spawnable = new HashSet<>(); + private final ArrayDeque pending = new ArrayDeque<>(); + private int cursor; + + private ScanState(ScanOrigin origin) { + this.origin = origin; + } + } +} diff --git a/src/main/java/games/dmg/spigotbase/SpawnableOverlayProgressionService.java b/src/main/java/games/dmg/spigotbase/SpawnableOverlayProgressionService.java new file mode 100644 index 0000000..f585490 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/SpawnableOverlayProgressionService.java @@ -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 + ); + } +} diff --git a/src/main/java/games/dmg/spigotbase/SpawnableOverlayProgressionUpdate.java b/src/main/java/games/dmg/spigotbase/SpawnableOverlayProgressionUpdate.java new file mode 100644 index 0000000..f103fe3 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/SpawnableOverlayProgressionUpdate.java @@ -0,0 +1,9 @@ +package games.dmg.spigotbase; + +record SpawnableOverlayProgressionUpdate(PlayerState player, boolean unlocked) { + SpawnableOverlayProgressionUpdate { + if (player == null) { + throw new IllegalArgumentException("player is required"); + } + } +} diff --git a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java index bf85e98..831a590 100644 --- a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java +++ b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java @@ -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."); diff --git a/src/main/java/games/dmg/spigotbase/YamlBaseStateRepository.java b/src/main/java/games/dmg/spigotbase/YamlBaseStateRepository.java index 7e0688a..7f16859 100644 --- a/src/main/java/games/dmg/spigotbase/YamlBaseStateRepository.java +++ b/src/main/java/games/dmg/spigotbase/YamlBaseStateRepository.java @@ -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(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) diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index a88f5a7..c63220e 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -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 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 895896c..74ef368 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -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. diff --git a/src/test/java/games/dmg/spigotbase/AdminProgressCounterTest.java b/src/test/java/games/dmg/spigotbase/AdminProgressCounterTest.java index 3edbbf5..fd5bf2e 100644 --- a/src/test/java/games/dmg/spigotbase/AdminProgressCounterTest.java +++ b/src/test/java/games/dmg/spigotbase/AdminProgressCounterTest.java @@ -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"); diff --git a/src/test/java/games/dmg/spigotbase/AdminProgressionServiceTest.java b/src/test/java/games/dmg/spigotbase/AdminProgressionServiceTest.java index ed9f22c..facc4cc 100644 --- a/src/test/java/games/dmg/spigotbase/AdminProgressionServiceTest.java +++ b/src/test/java/games/dmg/spigotbase/AdminProgressionServiceTest.java @@ -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"); diff --git a/src/test/java/games/dmg/spigotbase/BaseAdminCommandTest.java b/src/test/java/games/dmg/spigotbase/BaseAdminCommandTest.java index 6793e6c..f60f540 100644 --- a/src/test/java/games/dmg/spigotbase/BaseAdminCommandTest.java +++ b/src/test/java/games/dmg/spigotbase/BaseAdminCommandTest.java @@ -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 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 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 = 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 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[] {""}) ); } diff --git a/src/test/java/games/dmg/spigotbase/BaseProgressListenerTest.java b/src/test/java/games/dmg/spigotbase/BaseProgressListenerTest.java new file mode 100644 index 0000000..f945919 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/BaseProgressListenerTest.java @@ -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 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 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()); + } +} diff --git a/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java b/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java index 3a5540c..be3b296 100644 --- a/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java +++ b/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java @@ -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.argThat(message -> + message.contains("Spawnable Overlay:") + && message.contains("0/250 placements") + && message.contains("locked") + ) + ); verify(player, times(2)).sendMessage( org.mockito.ArgumentMatchers.argThat(message -> message.contains("Settings: visitors=") && message.contains("navigation=") && message.contains("flight=") && message.contains("border=") + && message.contains("spawnable=") && message.contains("bossbar=") ) ); diff --git a/src/test/java/games/dmg/spigotbase/PlayerStateTest.java b/src/test/java/games/dmg/spigotbase/PlayerStateTest.java index 489e7c3..15ac9fd 100644 --- a/src/test/java/games/dmg/spigotbase/PlayerStateTest.java +++ b/src/test/java/games/dmg/spigotbase/PlayerStateTest.java @@ -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(); diff --git a/src/test/java/games/dmg/spigotbase/PluginSettingsTest.java b/src/test/java/games/dmg/spigotbase/PluginSettingsTest.java index 36583db..b49ce57 100644 --- a/src/test/java/games/dmg/spigotbase/PluginSettingsTest.java +++ b/src/test/java/games/dmg/spigotbase/PluginSettingsTest.java @@ -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()); } diff --git a/src/test/java/games/dmg/spigotbase/SpawnableBlockPolicyTest.java b/src/test/java/games/dmg/spigotbase/SpawnableBlockPolicyTest.java new file mode 100644 index 0000000..8801665 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/SpawnableBlockPolicyTest.java @@ -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)); + } +} diff --git a/src/test/java/games/dmg/spigotbase/SpawnableOverlayControllerTest.java b/src/test/java/games/dmg/spigotbase/SpawnableOverlayControllerTest.java new file mode 100644 index 0000000..cd2528d --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/SpawnableOverlayControllerTest.java @@ -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) + ); + } +} diff --git a/src/test/java/games/dmg/spigotbase/SpawnableOverlayProgressionServiceTest.java b/src/test/java/games/dmg/spigotbase/SpawnableOverlayProgressionServiceTest.java new file mode 100644 index 0000000..bd68b25 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/SpawnableOverlayProgressionServiceTest.java @@ -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()); + } +} diff --git a/src/test/java/games/dmg/spigotbase/YamlBaseStateRepositoryTest.java b/src/test/java/games/dmg/spigotbase/YamlBaseStateRepositoryTest.java index 50b020e..d680bfc 100644 --- a/src/test/java/games/dmg/spigotbase/YamlBaseStateRepositoryTest.java +++ b/src/test/java/games/dmg/spigotbase/YamlBaseStateRepositoryTest.java @@ -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();