5 Commits
Author SHA1 Message Date
dmg 8a9640d992 fix(teleport): require same dimension for visits
Release / release (push) Successful in 2m24s
CI / build (push) Successful in 1m2s
2026-08-21 22:36:10 -04:00
dmg 3be3e43072 fix(teleport): enforce visitor warm-up
Release / release (push) Successful in 2m28s
CI / build (push) Successful in 1m3s
2026-08-21 22:28:34 -04:00
dmg 9692df9101 fix(flight): show boundary warning only while flying
Release / release (push) Successful in 4m20s
CI / build (push) Successful in 1m13s
2026-08-21 22:23:01 -04:00
dmg f36c40d5aa fix(navigation): hide guidance near base
Release / release (push) Successful in 2m22s
CI / build (push) Successful in 1m0s
2026-08-10 22:51:19 -04:00
dmg 259ea9410c feat(spawning): add spawnable block overlay
CI / build (push) Successful in 1m3s
Release / release (push) Successful in 2m16s
2026-08-10 22:46:02 -04:00
43 changed files with 1312 additions and 53 deletions
+8 -1
View File
@@ -32,9 +32,14 @@ The plugin JAR is written to `build/libs/`.
/basesettings navigation <enable|disable>
/basesettings flight <enable|disable>
/basesettings border <enable|disable>
/basesettings spawnable <enable|disable>
/basesettings bossbar <enable|disable>
```
Navigation particles appear only in the base's world and when the player is more than 25 blocks beyond the current base border.
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 +49,10 @@ The `spigotbase.admin` permission is granted to server operators by default. `/h
```text
/baseadmin status <player>
/baseadmin setlevel <player> <base|size|flight|warmup|cooldown> <level>
/baseadmin setprogress <player> <grass_dirt|stone|deepslate|obsidian|placements|base_breaks> <amount>
/baseadmin setprogress <player> <grass_dirt|stone|deepslate|obsidian|placements|total_placements|base_breaks> <amount>
/baseadmin setsetting <player> spawnable <enable|disable>
/baseadmin config <numeric-key> <value>
# e.g. /baseadmin config spawnable-overlay-unlock-placements 250
/baseadmin clearcooldown <player> [personal|visitor|all]
/baseadmin reset <player> <size|flight|warmup|cooldown>
/baseadmin reset <player> all confirm
+15
View File
@@ -76,3 +76,18 @@ 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`.
## 2026-08-10 — Navigation visibility buffer
- Limited enabled base navigation particles to the base's world and positions more than 25 horizontal blocks beyond the current base border.
- Used the live size-tier radius so expansion and relocation immediately update the visibility threshold.
- Added exact-boundary and controller integration coverage.
- Verified the change with `./gradlew clean check jar`.
+1
View File
@@ -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)
@@ -14,7 +14,9 @@ As a **player with Base I**, I want visual guidance toward my base so that I can
- [x] Base II requires Base I and an established base.
- [x] Base II unlocks at a configurable cumulative grass-or-dirt threshold that defaults to 500 blocks, 250 more than Base I.
- [x] Base II provides particle-based navigation and does not grant or require a physical compass item.
- [x] While enabled and in the base's world, particles are drawn along the ground to indicate the direction toward the base.
- [x] While enabled and in the base's world, particles are drawn along the ground only when the player is horizontally more than 25 blocks beyond the current base border.
- [x] No navigation particles appear inside the base, exactly 25 blocks beyond its border, or anywhere within that buffer.
- [x] Expansion and relocation update the navigation visibility threshold immediately.
- [x] Particle generation is bounded to avoid excessive server or client load.
- [x] A player in another world receives a clear message instead of a misleading particle direction.
- [x] `/basesettings navigation enable` enables guidance idempotently after Base II is unlocked, and `/basesettings navigation disable` disables it idempotently.
@@ -21,7 +21,7 @@ As a **player with Base I**, I want to unlock controlled flight around my base s
- [x] Flight II expands the vertical range to 100 blocks below and above base Y by default.
- [x] Flight III expands the vertical range to the world's minimum and maximum build heights.
- [x] A configurable five-block horizontal warning buffer extends beyond the current base radius.
- [x] Plugin-granted flight remains active in the warning buffer and displays prominent on-screen notice that the player is leaving the base.
- [x] Plugin-granted flight remains active in the warning buffer, and a prominent on-screen notice that the player is leaving the base appears only while the player is actively flying.
- [x] Passing beyond the warning buffer removes only flight granted by this plugin.
- [x] Flight is not granted outside the unlocked vertical range.
- [x] `/basesettings flight enable` enables unlocked flight idempotently, and `/basesettings flight disable` disables it idempotently.
@@ -21,7 +21,8 @@ As a **player with Base III**, I want to open my base to visitors so that other
- [x] `/gotobase <owner>` autocompletes bases that the requesting player is currently eligible to visit.
- [x] `/visit <owner>` aliases `/gotobase <owner>` with identical autocomplete, including eligible bases whose owners are offline.
- [x] Enabled bases remain visitable while their owners are offline.
- [x] A visitor teleport uses the destination owner's current warm-up tier.
- [x] `/visit` and `/gotobase` require the visitor to be in the destination base's recorded world; cross-dimension requests are rejected before warm-up with a clear message and without consuming cooldown.
- [x] A visitor teleport uses the destination owner's current warm-up tier, but always requires at least a one-second stationary warm-up; `/visit` and `/gotobase` never teleport instantly.
- [x] Looking around is permitted, while movement between block coordinates, damage, teleportation, world change, death, logout, or a conflicting teleport cancels the visitor warm-up.
- [x] Cancellation or destination failure does not consume a visitor cooldown.
- [x] A safe destination is resolved at or near the owner's recorded base center.
@@ -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 <player> spawnable <enable|disable>`.
- [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)
@@ -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>");
}
}
@@ -112,7 +112,7 @@ final class BaseFlightController implements Runnable {
player.setAllowFlight(true);
grantedFlight.add(player.getUniqueId());
}
if (distanceSquared > (double) radius * radius) {
if (distanceSquared > (double) radius * radius && player.isFlying()) {
if (warned.add(player.getUniqueId())) {
player.sendTitle(
ChatColor.RED + "Leaving Your Base",
@@ -10,6 +10,7 @@ final class BaseNavigationController implements Runnable {
private final Server server;
private final BaseStateManager stateManager;
private final PluginSettingsProvider settings;
private final BaseBoundsService boundsService;
BaseNavigationController(
Server server,
@@ -19,6 +20,7 @@ final class BaseNavigationController implements Runnable {
this.server = server;
this.stateManager = stateManager;
this.settings = settings;
this.boundsService = new BaseBoundsService(settings);
}
@Override
@@ -32,7 +34,16 @@ final class BaseNavigationController implements Runnable {
if (!player.getWorld().getUID().equals(base.worldId())) {
continue;
}
Location origin = player.getLocation().clone().add(0.0, 0.15, 0.0);
Location playerLocation = player.getLocation();
if (!BaseNavigationVisibility.isBeyondBorderBuffer(
base,
boundsService.radius(state),
playerLocation.getX(),
playerLocation.getZ()
)) {
continue;
}
Location origin = playerLocation.clone().add(0.0, 0.15, 0.0);
Vector direction = new Vector(
base.x() + 0.5 - origin.getX(),
0.0,
@@ -0,0 +1,26 @@
package games.dmg.spigotbase;
final class BaseNavigationVisibility {
static final int BORDER_BUFFER = 25;
private BaseNavigationVisibility() {
}
static boolean isBeyondBorderBuffer(
BaseLocation base,
int radius,
double playerX,
double playerZ
) {
if (base == null) {
throw new IllegalArgumentException("base is required");
}
if (radius <= 0) {
throw new IllegalArgumentException("base radius must be positive");
}
double deltaX = playerX - (base.x() + 0.5);
double deltaZ = playerZ - (base.z() + 0.5);
double minimumDistance = radius + BORDER_BUFFER;
return deltaX * deltaX + deltaZ * deltaZ > minimumDistance * minimumDistance;
}
}
@@ -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>]");
}
}
@@ -83,6 +83,13 @@ final class BaseTeleportManager implements Listener {
visitor.sendMessage(ChatColor.RED + "That base is not accepting visitors.");
return;
}
BaseLocation destination = owner.base().orElseThrow();
if (!visitor.getWorld().getUID().equals(destination.worldId())) {
visitor.sendMessage(
ChatColor.RED + "You must be in the same dimension as that base to visit it."
);
return;
}
PlayerState visitorState = stateManager.player(visitor.getUniqueId(), visitor.getName());
Optional<Duration> remaining = visitorPolicy.remaining(
visitorState, owner.playerId(), clock.instant()
@@ -94,8 +101,8 @@ final class BaseTeleportManager implements Listener {
}
begin(
visitor,
owner.base().orElseThrow(),
policy.warmup(owner),
destination,
policy.visitorWarmup(owner),
owner.playerId(),
policy.cooldown(owner),
"visit " + owner.latestName() + "'s base"
@@ -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.");
@@ -25,6 +25,13 @@ public final class TeleportPolicy {
};
}
public Duration visitorWarmup(PlayerState owner) {
Duration warmup = warmup(owner);
return warmup.compareTo(Duration.ofSeconds(1)) < 0
? Duration.ofSeconds(1)
: warmup;
}
public Duration cooldown(PlayerState player) {
return switch (player.cooldownLevel()) {
case 0 -> Duration.ofSeconds(settings.current().initialTeleportCooldownSeconds());
@@ -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,93 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
final class BaseFlightControllerTest {
private final UUID playerId = UUID.randomUUID();
private final UUID worldId = UUID.randomUUID();
private final Server server = mock(Server.class);
private final BaseStateManager stateManager = mock(BaseStateManager.class);
private final Player player = mock(Player.class);
private final PlayerState state = PlayerState.newPlayer(playerId, "Alex")
.withGrassAndDirtProgress(250, 1)
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH)
.withFlightLevel(1, true);
private final BaseFlightController controller = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
@BeforeEach
void setUp() {
World world = mock(World.class);
Location location = mock(Location.class);
PlayerInventory inventory = mock(PlayerInventory.class);
doReturn(List.of(player)).when(server).getOnlinePlayers();
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Alex");
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[0]);
when(stateManager.player(playerId, "Alex")).thenReturn(state);
when(player.getWorld()).thenReturn(world);
when(world.getUID()).thenReturn(worldId);
when(player.getLocation()).thenReturn(location);
when(location.getX()).thenReturn(12.5);
when(location.getZ()).thenReturn(0.5);
when(location.getBlockY()).thenReturn(64);
when(player.getAllowFlight()).thenReturn(true);
}
@Test
void walkingInWarningBufferDoesNotShowLeavingBaseWarning() {
when(player.isFlying()).thenReturn(false);
controller.run();
verify(player, never()).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
}
@Test
void flyingInWarningBufferShowsLeavingBaseWarning() {
when(player.isFlying()).thenReturn(true);
controller.run();
verify(player).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
}
@Test
void warningCanAppearAgainAfterPlayerStopsAndResumesFlyingInBuffer() {
when(player.isFlying()).thenReturn(true, false, true);
controller.run();
controller.run();
controller.run();
verify(player, times(2)).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
}
}
@@ -0,0 +1,60 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.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.entity.Player;
import org.junit.jupiter.api.Test;
final class BaseNavigationControllerTest {
@Test
void showsParticlesOnlyMoreThanTwentyFiveBlocksBeyondBorder() {
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PlayerState state = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(2, 0, 0, 0, 0, true, false, false)
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH);
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
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, 35.5, 64.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())
);
new BaseNavigationController(server, stateManager, settings).run();
verify(player, never()).spawnParticle(
eq(Particle.END_ROD), any(Location.class), eq(1),
eq(0.0), eq(0.0), eq(0.0), eq(0.0)
);
when(player.getLocation()).thenReturn(new Location(world, 35.51, 64.0, 0.5));
new BaseNavigationController(server, stateManager, settings).run();
verify(player, times(settings.current().navigationParticleCount())).spawnParticle(
eq(Particle.END_ROD), any(Location.class), eq(1),
eq(0.0), eq(0.0), eq(0.0), eq(0.0)
);
}
}
@@ -0,0 +1,26 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class BaseNavigationVisibilityTest {
private static final BaseLocation BASE = new BaseLocation(
UUID.randomUUID(), "world", 0, 64, 0, 0, 0
);
@Test
void hidesNavigationAtAndWithinTwentyFiveBlocksBeyondBorder() {
assertFalse(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 10, 35.5, 0.5));
assertFalse(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 10, 20.5, 0.5));
}
@Test
void showsNavigationMoreThanTwentyFiveBlocksBeyondCurrentBorder() {
assertTrue(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 10, 35.51, 0.5));
assertFalse(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 75, 100.5, 0.5));
assertTrue(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 75, 100.51, 0.5));
}
}
@@ -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=")
)
);
@@ -0,0 +1,64 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.junit.jupiter.api.Test;
final class BaseTeleportManagerTest {
@Test
void visitFromAnotherWorldIsRejectedBeforeWarmupAndCooldownChecks() {
UUID visitorId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
UUID baseWorldId = UUID.randomUUID();
BaseStateManager stateManager = mock(BaseStateManager.class);
TeleportPolicy teleportPolicy = mock(TeleportPolicy.class);
VisitorPolicy visitorPolicy = mock(VisitorPolicy.class);
Player visitor = mock(Player.class);
World visitorWorld = mock(World.class);
PlayerState owner = PlayerState.newPlayer(ownerId, "Owner")
.withGrassAndDirtProgress(500, 4)
.withBase(
new BaseLocation(baseWorldId, "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withVisitorsEnabled(true);
PlayerState visitorState = PlayerState.newPlayer(visitorId, "Visitor");
BaseTeleportManager manager = new BaseTeleportManager(
mock(Plugin.class),
stateManager,
teleportPolicy,
visitorPolicy,
mock(SafeBaseDestination.class),
Clock.systemUTC()
);
when(visitor.getUniqueId()).thenReturn(visitorId);
when(visitor.getName()).thenReturn("Visitor");
when(visitor.getWorld()).thenReturn(visitorWorld);
when(visitorWorld.getUID()).thenReturn(UUID.randomUUID());
when(stateManager.player(visitorId, "Visitor")).thenReturn(visitorState);
when(visitorPolicy.remaining(eq(visitorState), eq(ownerId), any()))
.thenReturn(Optional.of(Duration.ofSeconds(1)));
manager.startVisit(visitor, owner);
verify(visitor).sendMessage(
ChatColor.RED + "You must be in the same dimension as that base to visit it."
);
verifyNoInteractions(stateManager, teleportPolicy, visitorPolicy);
}
}
@@ -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());
}
}
@@ -22,6 +22,17 @@ final class TeleportPolicyTest {
assertEquals(Duration.ZERO, policy.warmup(player.withTeleportLevels(3, 0)));
}
@Test
void visitorWarmupHasOneSecondMinimum() {
PlayerState player = baseThreePlayer();
assertEquals(Duration.ofSeconds(30), policy.visitorWarmup(player));
assertEquals(
Duration.ofSeconds(1),
policy.visitorWarmup(player.withTeleportLevels(3, 0))
);
}
@Test
void cooldownLevelsUseApprovedDurations() {
PlayerState player = baseThreePlayer();
@@ -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();