feat(base): implement progression system

This commit is contained in:
dmg
2026-08-09 15:01:44 -04:00
commit c87d923cad
76 changed files with 5092 additions and 0 deletions
@@ -0,0 +1,83 @@
package games.dmg.spigotbase;
public final class AdminProgressionService {
public PlayerState setLevel(PlayerState player, ProgressionPath path, int level) {
int base = player.baseLevel();
int size = player.sizeLevel();
int flight = player.flightLevel();
int warmup = player.warmupLevel();
int cooldown = player.cooldownLevel();
switch (path) {
case BASE -> {
requireRange(level, 0, 4, "base");
base = level;
if (base < 3) {
warmup = 0;
cooldown = 0;
}
if (base < 1) {
size = 0;
flight = 0;
}
}
case SIZE -> {
requireRange(level, 0, 3, "size");
size = level;
if (level > 0) {
base = Math.max(base, 1);
}
}
case FLIGHT -> {
requireRange(level, 0, 3, "flight");
flight = level;
if (level > 0) {
base = Math.max(base, 1);
}
}
case WARMUP -> {
requireRange(level, 0, 3, "warm-up");
warmup = level;
if (level > 0) {
base = Math.max(base, 3);
}
}
case COOLDOWN -> {
requireRange(level, 0, 4, "cooldown");
cooldown = level;
if (level > 0) {
base = Math.max(base, 3);
}
}
}
boolean navigationEnabled = base >= 2 && player.navigationEnabled();
boolean flightEnabled = flight > 0 && player.flightEnabled();
boolean visitorsEnabled = base >= 4 && player.visitorsEnabled();
if (path == ProgressionPath.BASE) {
navigationEnabled = base >= 2;
visitorsEnabled = base >= 4;
}
if (path == ProgressionPath.FLIGHT) {
flightEnabled = flight > 0;
}
return player.withAdministrativeLevels(
base,
size,
flight,
warmup,
cooldown,
navigationEnabled,
flightEnabled,
visitorsEnabled
);
}
private static void requireRange(int value, int minimum, int maximum, String path) {
if (value < minimum || value > maximum) {
throw new IllegalArgumentException(
path + " level must be between " + minimum + " and " + maximum
);
}
}
}
@@ -0,0 +1,172 @@
package games.dmg.spigotbase;
import java.util.Arrays;
import java.util.Locale;
import java.util.Optional;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class BaseAdminCommand implements CommandExecutor {
private final BaseStateManager stateManager;
private final AdminProgressionService progressionService;
BaseAdminCommand(BaseStateManager stateManager, AdminProgressionService progressionService) {
this.stateManager = stateManager;
this.progressionService = progressionService;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!sender.hasPermission("spigotbase.admin")) {
sender.sendMessage(ChatColor.RED + "You do not have permission to administer Spigot Base.");
return true;
}
if (arguments.length < 2) {
sendUsage(sender);
return true;
}
Optional<PlayerState> target = resolve(arguments[1]);
if (target.isEmpty()) {
sender.sendMessage(ChatColor.RED + "That player is not online or previously known.");
return true;
}
return switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "progress" -> showProgress(sender, target.orElseThrow());
case "setlevel" -> setLevel(sender, target.orElseThrow(), arguments);
case "clearcooldown" -> clearCooldown(sender, target.orElseThrow(), arguments);
case "reset" -> reset(sender, target.orElseThrow(), arguments);
default -> {
sendUsage(sender);
yield true;
}
};
}
private boolean showProgress(CommandSender sender, PlayerState player) {
sender.sendMessage(ChatColor.GOLD + "=== " + player.latestName() + " Base Progress ===");
sender.sendMessage(ChatColor.YELLOW + "Levels: base=" + player.baseLevel()
+ " size=" + player.sizeLevel() + " flight=" + player.flightLevel()
+ " warmup=" + player.warmupLevel() + " cooldown=" + player.cooldownLevel());
sender.sendMessage(ChatColor.GRAY + "Grass/dirt=" + player.grassAndDirtBroken()
+ " stone=" + player.stoneBroken() + " deepslate=" + player.deepslateBroken()
+ " obsidian=" + player.obsidianBroken());
sender.sendMessage(ChatColor.GRAY + "In-base placements=" + player.blocksPlacedInBase()
+ " breaks=" + player.blocksBrokenInBase());
sender.sendMessage(ChatColor.GRAY + "Toggles: navigation=" + player.navigationEnabled()
+ " flight=" + player.flightEnabled() + " 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"));
sender.sendMessage(ChatColor.GRAY + "Visitor cooldowns=" + player.visitorCooldownUntil().size());
return true;
}
private boolean setLevel(
CommandSender sender,
PlayerState target,
String[] arguments
) {
if (arguments.length != 4) {
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin setlevel <player> <path> <level>");
return true;
}
try {
ProgressionPath path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
int level = Integer.parseInt(arguments[3]);
PlayerState updated = stateManager.update(
target.playerId(),
target.latestName(),
current -> progressionService.setLevel(current, path, level)
);
stateManager.saveIfDirty();
sender.sendMessage(ChatColor.GREEN + "Set " + updated.latestName() + "'s "
+ path.name().toLowerCase(Locale.ROOT) + " level to " + level + ".");
} catch (IllegalArgumentException exception) {
sender.sendMessage(ChatColor.RED + exception.getMessage());
}
return true;
}
private boolean clearCooldown(
CommandSender sender,
PlayerState target,
String[] arguments
) {
String selection = arguments.length >= 3
? arguments[2].toLowerCase(Locale.ROOT)
: "all";
if (!Arrays.asList("personal", "visitor", "all").contains(selection)) {
sender.sendMessage(ChatColor.RED
+ "Usage: /baseadmin clearcooldown <player> [personal|visitor|all]");
return true;
}
stateManager.update(target.playerId(), target.latestName(), current -> {
PlayerState updated = current;
if (selection.equals("personal") || selection.equals("all")) {
updated = updated.withoutPersonalCooldown();
}
if (selection.equals("visitor") || selection.equals("all")) {
updated = updated.withoutVisitorCooldowns();
}
return updated;
});
stateManager.saveIfDirty();
sender.sendMessage(ChatColor.GREEN + "Cleared " + selection + " cooldowns for "
+ target.latestName() + ".");
return true;
}
private boolean reset(CommandSender sender, PlayerState target, String[] arguments) {
if (arguments.length < 3) {
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin reset <player> <path|all> [confirm]");
return true;
}
if (arguments[2].equalsIgnoreCase("all")) {
if (arguments.length != 4 || !arguments[3].equalsIgnoreCase("confirm")) {
sender.sendMessage(ChatColor.RED + "Repeat with: /baseadmin reset "
+ target.latestName() + " all confirm");
return true;
}
stateManager.update(
target.playerId(),
target.latestName(),
ignored -> PlayerState.newPlayer(target.playerId(), target.latestName())
);
stateManager.saveIfDirty();
sender.sendMessage(ChatColor.GREEN + "Reset all progression for " + target.latestName() + ".");
return true;
}
try {
ProgressionPath path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
stateManager.update(
target.playerId(),
target.latestName(),
current -> progressionService.setLevel(current, path, 0)
);
stateManager.saveIfDirty();
sender.sendMessage(ChatColor.GREEN + "Reset " + path.name().toLowerCase(Locale.ROOT)
+ " progression for " + target.latestName() + ".");
} catch (IllegalArgumentException exception) {
sender.sendMessage(ChatColor.RED + exception.getMessage());
}
return true;
}
private Optional<PlayerState> resolve(String name) {
Player online = Bukkit.getPlayerExact(name);
if (online != null) {
return Optional.of(stateManager.player(online.getUniqueId(), online.getName()));
}
return stateManager.findByName(name);
}
private static void sendUsage(CommandSender sender) {
sender.sendMessage(ChatColor.YELLOW + "Usage: /baseadmin "
+ "<progress|setlevel|clearcooldown|reset> <player> ...");
}
}
@@ -0,0 +1,27 @@
package games.dmg.spigotbase;
import java.util.UUID;
public record BaseArea(BaseLocation center, int radius, int verticalRange) {
public BaseArea {
if (center == null) {
throw new IllegalArgumentException("center is required");
}
if (radius <= 0 || verticalRange <= 0) {
throw new IllegalArgumentException("base dimensions must be positive");
}
}
public boolean contains(UUID worldId, int x, int y, int z) {
if (!center.worldId().equals(worldId)) {
return false;
}
long deltaX = (long) x - center.x();
long deltaZ = (long) z - center.z();
long horizontalDistanceSquared = deltaX * deltaX + deltaZ * deltaZ;
long radiusSquared = (long) radius * radius;
long minimumY = (long) center.y() - verticalRange;
long maximumY = (long) center.y() + verticalRange;
return horizontalDistanceSquared <= radiusSquared && y >= minimumY && y <= maximumY;
}
}
@@ -0,0 +1,35 @@
package games.dmg.spigotbase;
public final class BaseBoundsService {
private final PluginSettings settings;
public BaseBoundsService(PluginSettings settings) {
this.settings = settings;
}
public int radius(PlayerState player) {
return switch (player.sizeLevel()) {
case 0 -> settings.initialRadius();
case 1 -> settings.firstExpandedRadius();
case 2 -> settings.secondExpandedRadius();
case 3 -> settings.thirdExpandedRadius();
default -> throw new IllegalArgumentException("unknown size level");
};
}
public int verticalRange(PlayerState player) {
return switch (player.flightLevel()) {
case 0, 1 -> settings.initialVerticalRange();
case 2 -> settings.secondFlightVerticalRange();
case 3 -> Integer.MAX_VALUE;
default -> throw new IllegalArgumentException("unknown flight level");
};
}
public BaseArea area(PlayerState player) {
BaseLocation base = player.base().orElseThrow(() ->
new IllegalStateException("player has not established a base")
);
return new BaseArea(base, radius(player), verticalRange(player));
}
}
@@ -0,0 +1,123 @@
package games.dmg.spigotbase;
import java.io.IOException;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
final class BaseCommand implements CommandExecutor {
private final BaseTeleportManager teleportManager;
private final BaseStateManager stateManager;
private final VisitorPolicy visitorPolicy;
private final PluginSettings settings;
BaseCommand(
BaseTeleportManager teleportManager,
BaseStateManager stateManager,
VisitorPolicy visitorPolicy,
PluginSettings settings
) {
this.teleportManager = teleportManager;
this.stateManager = stateManager;
this.visitorPolicy = visitorPolicy;
this.settings = settings;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can use a base.");
return true;
}
if (arguments.length == 0) {
teleportManager.start(player);
return true;
}
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("upgrade")) {
purchaseVisitorAccess(player);
return true;
}
player.sendMessage(ChatColor.RED + "Usage: /base [upgrade]");
return true;
}
private void purchaseVisitorAccess(Player player) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (!visitorPolicy.canPurchase(state)) {
player.sendMessage(ChatColor.RED + (state.baseLevel() >= 4
? "Base IV is already unlocked."
: "You must unlock Base III before purchasing Base IV."));
return;
}
int price = settings.visitorUnlockDiamondCost();
PlayerInventory inventory = player.getInventory();
if (countDiamonds(inventory) < price) {
player.sendMessage(ChatColor.RED + "Base IV costs " + price + " diamonds.");
return;
}
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
removeDiamonds(inventory, price);
try {
stateManager.updateAndSave(
player.getUniqueId(),
player.getName(),
current -> current.withBaseLevel(4).withVisitorsEnabled(true)
);
} catch (IOException | RuntimeException exception) {
inventory.setStorageContents(snapshot);
player.sendMessage(ChatColor.RED + "The upgrade could not be saved; your diamonds were restored.");
return;
}
player.sendTitle(
ChatColor.GOLD + "Base IV Unlocked",
ChatColor.YELLOW + "Visitors may now teleport to your base",
10, 70, 20
);
player.sendMessage(ChatColor.GREEN + "Base IV unlocked for " + price + " diamonds.");
}
private static int countDiamonds(PlayerInventory inventory) {
int count = 0;
for (ItemStack item : inventory.getStorageContents()) {
if (item != null && item.getType() == Material.DIAMOND) {
count += item.getAmount();
}
}
return count;
}
private static void removeDiamonds(PlayerInventory inventory, int amount) {
ItemStack[] contents = inventory.getStorageContents();
int remaining = amount;
for (int index = 0; index < contents.length && remaining > 0; index++) {
ItemStack item = contents[index];
if (item == null || item.getType() != Material.DIAMOND) {
continue;
}
int removed = Math.min(remaining, item.getAmount());
remaining -= removed;
int newAmount = item.getAmount() - removed;
if (newAmount == 0) {
contents[index] = null;
} else {
ItemStack reduced = item.clone();
reduced.setAmount(newAmount);
contents[index] = reduced;
}
}
inventory.setStorageContents(contents);
}
private static ItemStack[] cloneContents(ItemStack[] contents) {
ItemStack[] copy = new ItemStack[contents.length];
for (int index = 0; index < contents.length; index++) {
copy[index] = contents[index] == null ? null : contents[index].clone();
}
return copy;
}
}
@@ -0,0 +1,43 @@
package games.dmg.spigotbase;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class BaseFlightCommand implements CommandExecutor {
private final BaseStateManager stateManager;
private final BaseFlightController controller;
BaseFlightCommand(BaseStateManager stateManager, BaseFlightController controller) {
this.stateManager = stateManager;
this.controller = controller;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can use base flight.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (state.flightLevel() < 1) {
player.sendMessage(ChatColor.RED + "Base flight is still locked.");
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withFlightEnabled(!current.flightEnabled())
);
if (!state.flightEnabled()) {
controller.removeGrantedFlight(player);
}
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Base flight is now "
+ (state.flightEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
+ ChatColor.YELLOW + ".");
return true;
}
}
@@ -0,0 +1,156 @@
package games.dmg.spigotbase;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.GameMode;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
final class BaseFlightController implements Runnable {
private final Server server;
private final BaseStateManager stateManager;
private final SecondaryProgressionService progressionService;
private final BaseBoundsService boundsService;
private final PluginSettings settings;
private final Set<UUID> grantedFlight = new HashSet<>();
private final Set<UUID> warned = new HashSet<>();
BaseFlightController(
Server server,
BaseStateManager stateManager,
SecondaryProgressionService progressionService,
BaseBoundsService boundsService,
PluginSettings settings
) {
this.server = server;
this.stateManager = stateManager;
this.progressionService = progressionService;
this.boundsService = boundsService;
this.settings = settings;
}
@Override
public void run() {
for (Player player : server.getOnlinePlayers()) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (player.getGameMode() == GameMode.SURVIVAL) {
state = observeElytra(player, state);
}
applyFlight(player, state);
}
grantedFlight.removeIf(id -> server.getPlayer(id) == null);
warned.removeIf(id -> server.getPlayer(id) == null);
}
void removeGrantedFlight(Player player) {
UUID playerId = player.getUniqueId();
warned.remove(playerId);
if (grantedFlight.remove(playerId)
&& player.getGameMode() != GameMode.CREATIVE
&& player.getGameMode() != GameMode.SPECTATOR) {
player.setFlying(false);
player.setAllowFlight(false);
}
}
void removeAllGrantedFlight() {
for (UUID playerId : Set.copyOf(grantedFlight)) {
Player player = server.getPlayer(playerId);
if (player != null) {
removeGrantedFlight(player);
}
}
grantedFlight.clear();
warned.clear();
}
private PlayerState observeElytra(Player player, PlayerState state) {
int count = countElytra(player);
ProgressionUpdate update = progressionService.observeElytraCount(state, count);
if (!update.unlockedFlightLevel()) {
return state;
}
PlayerState updated = stateManager.update(
player.getUniqueId(), player.getName(), ignored -> update.player()
);
stateManager.saveIfDirty();
player.sendTitle(
ChatColor.GOLD + "Base Flight " + roman(updated.flightLevel()) + " Unlocked",
ChatColor.YELLOW + "Use /baseflight to toggle flight",
10, 70, 20
);
return updated;
}
private void applyFlight(Player player, PlayerState state) {
if (player.getGameMode() != GameMode.SURVIVAL
|| !state.flightEnabled() || state.flightLevel() < 1 || state.base().isEmpty()) {
removeGrantedFlight(player);
return;
}
BaseLocation base = state.base().orElseThrow();
if (!player.getWorld().getUID().equals(base.worldId()) || !withinVerticalRange(player, state, base)) {
removeGrantedFlight(player);
return;
}
double deltaX = player.getLocation().getX() - (base.x() + 0.5);
double deltaZ = player.getLocation().getZ() - (base.z() + 0.5);
double distanceSquared = deltaX * deltaX + deltaZ * deltaZ;
int radius = boundsService.radius(state);
int bufferedRadius = radius + settings.flightWarningBuffer();
if (distanceSquared > (double) bufferedRadius * bufferedRadius) {
removeGrantedFlight(player);
return;
}
if (!player.getAllowFlight()) {
player.setAllowFlight(true);
grantedFlight.add(player.getUniqueId());
}
if (distanceSquared > (double) radius * radius) {
if (warned.add(player.getUniqueId())) {
player.sendTitle(
ChatColor.RED + "Leaving Your Base",
ChatColor.YELLOW + "Turn back before base flight ends",
0, 30, 10
);
}
} else {
warned.remove(player.getUniqueId());
}
}
private boolean withinVerticalRange(Player player, PlayerState state, BaseLocation base) {
int y = player.getLocation().getBlockY();
if (state.flightLevel() == 3) {
return y >= player.getWorld().getMinHeight() && y < player.getWorld().getMaxHeight();
}
return Math.abs((long) y - base.y()) <= boundsService.verticalRange(state);
}
private static int countElytra(Player player) {
int count = 0;
for (ItemStack item : player.getInventory().getStorageContents()) {
if (item != null && item.getType() == Material.ELYTRA) {
count += item.getAmount();
}
}
ItemStack chest = player.getInventory().getChestplate();
if (chest != null && chest.getType() == Material.ELYTRA) {
count += chest.getAmount();
}
return Math.min(3, count);
}
private static String roman(int level) {
return switch (level) {
case 1 -> "I";
case 2 -> "II";
case 3 -> "III";
default -> Integer.toString(level);
};
}
}
@@ -0,0 +1,25 @@
package games.dmg.spigotbase;
import java.util.UUID;
public record BaseLocation(
UUID worldId,
String worldName,
int x,
int y,
int z,
float yaw,
float pitch
) {
public BaseLocation {
if (worldId == null) {
throw new IllegalArgumentException("world ID is required");
}
if (worldName == null || worldName.isBlank()) {
throw new IllegalArgumentException("world name is required");
}
if (!Float.isFinite(yaw) || !Float.isFinite(pitch)) {
throw new IllegalArgumentException("rotation must be finite");
}
}
}
@@ -0,0 +1,47 @@
package games.dmg.spigotbase;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class BaseNavigationCommand implements CommandExecutor {
private final BaseStateManager stateManager;
BaseNavigationCommand(BaseStateManager stateManager) {
this.stateManager = stateManager;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can use base navigation.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (state.baseLevel() < 2) {
player.sendMessage(ChatColor.RED + "Base II navigation is still locked.");
return true;
}
if (state.base().isEmpty()) {
player.sendMessage(ChatColor.RED + "Set your base before enabling navigation.");
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withNavigationEnabled(!current.navigationEnabled())
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Base navigation is now "
+ (state.navigationEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
+ ChatColor.YELLOW + ".");
if (state.navigationEnabled()
&& !player.getWorld().getUID().equals(state.base().orElseThrow().worldId())) {
player.sendMessage(ChatColor.RED + "Your base is in another world: "
+ state.base().orElseThrow().worldName() + ".");
}
return true;
}
}
@@ -0,0 +1,45 @@
package games.dmg.spigotbase;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.util.Vector;
final class BaseNavigationController implements Runnable {
private final Server server;
private final BaseStateManager stateManager;
BaseNavigationController(Server server, BaseStateManager stateManager) {
this.server = server;
this.stateManager = stateManager;
}
@Override
public void run() {
for (Player player : server.getOnlinePlayers()) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (!state.navigationEnabled() || state.base().isEmpty()) {
continue;
}
BaseLocation base = state.base().orElseThrow();
if (!player.getWorld().getUID().equals(base.worldId())) {
continue;
}
Location origin = player.getLocation().clone().add(0.0, 0.15, 0.0);
Vector direction = new Vector(
base.x() + 0.5 - origin.getX(),
0.0,
base.z() + 0.5 - origin.getZ()
);
if (direction.lengthSquared() < 1.0) {
continue;
}
direction.normalize();
for (int step = 1; step <= 5; step++) {
Location particle = origin.clone().add(direction.clone().multiply(step));
player.spawnParticle(Particle.END_ROD, particle, 1, 0.0, 0.0, 0.0, 0.0);
}
}
}
}
@@ -0,0 +1,121 @@
package games.dmg.spigotbase;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class BaseProgressCommand implements CommandExecutor {
private final BaseStateManager stateManager;
private final PluginSettings settings;
private final TeleportPolicy teleportPolicy;
BaseProgressCommand(BaseStateManager stateManager, PluginSettings settings) {
this.stateManager = stateManager;
this.settings = settings;
this.teleportPolicy = new TeleportPolicy(settings);
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players have base progression.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("bossbar")) {
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withBossBarEnabled(!current.bossBarEnabled())
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Automatic progress boss bars are now "
+ (state.bossBarEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
+ ChatColor.YELLOW + ".");
return true;
}
player.sendMessage(ChatColor.GOLD + "=== Base Progress ===");
showBasePath(player, state);
showSizePath(player, state);
showFlightPath(player, state);
showWarmupPath(player, state);
showCooldownPath(player, state);
player.sendMessage(ChatColor.GRAY + "Boss bars: " + (state.bossBarEnabled() ? "on" : "off"));
state.base().ifPresentOrElse(
base -> player.sendMessage(ChatColor.GRAY + "Base: " + base.worldName() + " "
+ base.x() + ", " + base.y() + ", " + base.z()),
() -> player.sendMessage(ChatColor.GRAY + "Base: not set")
);
return true;
}
private void showBasePath(Player player, PlayerState state) {
String detail = switch (state.baseLevel()) {
case 0 -> state.grassAndDirtBroken() + "/" + settings.baseUnlockBlocks()
+ " grass or dirt → /setbase";
case 1 -> state.grassAndDirtBroken() + "/" + settings.navigationUnlockBlocks()
+ " grass or dirt → navigation";
case 2 -> state.blocksPlacedInBase() + "/" + settings.teleportUnlockPlacements()
+ " placements → /base";
case 3 -> settings.visitorUnlockDiamondCost() + " diamonds → visitor access";
case 4 -> "complete; visitor access unlocked";
default -> "invalid";
};
player.sendMessage(ChatColor.YELLOW + "Base " + state.baseLevel() + "/4: "
+ ChatColor.GRAY + detail);
}
private void showSizePath(Player player, PlayerState state) {
String detail = switch (state.sizeLevel()) {
case 0 -> state.stoneBroken() + "/" + settings.stoneExpansionBlocks() + " stone";
case 1 -> state.deepslateBroken() + "/" + settings.deepslateExpansionBlocks() + " deepslate";
case 2 -> state.obsidianBroken() + "/" + settings.obsidianExpansionBlocks() + " obsidian";
case 3 -> "complete; 150-block radius by default";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Size "
+ state.sizeLevel() + "/3: " + ChatColor.GRAY + detail);
}
private void showFlightPath(Player player, PlayerState state) {
String detail = state.flightLevel() >= 3
? "complete; world build height"
: (state.flightLevel() + 1) + " simultaneous elytra required";
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Flight "
+ state.flightLevel() + "/3: " + ChatColor.GRAY + detail
+ "; toggle=" + (state.flightEnabled() ? "on" : "off"));
}
private void showWarmupPath(Player player, PlayerState state) {
String detail = switch (state.warmupLevel()) {
case 0 -> state.blocksPlacedInBase() + "/" + settings.secondWarmupPlacements();
case 1 -> state.blocksPlacedInBase() + "/" + settings.thirdWarmupPlacements();
case 2 -> state.blocksPlacedInBase() + "/" + settings.instantWarmupPlacements();
case 3 -> "complete";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Warm-up "
+ state.warmupLevel() + "/3: " + ChatColor.GRAY
+ DurationFormatter.friendly(teleportPolicy.warmup(state)) + "; " + detail);
}
private void showCooldownPath(Player player, PlayerState state) {
String detail = switch (state.cooldownLevel()) {
case 0 -> state.blocksBrokenInBase() + "/" + settings.firstCooldownBreaks();
case 1 -> state.blocksBrokenInBase() + "/" + settings.secondCooldownBreaks();
case 2 -> state.blocksBrokenInBase() + "/" + settings.thirdCooldownBreaks();
case 3 -> state.blocksBrokenInBase() + "/" + settings.instantCooldownBreaks();
case 4 -> "complete";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Cooldown "
+ state.cooldownLevel() + "/4: " + ChatColor.GRAY
+ DurationFormatter.friendly(teleportPolicy.cooldown(state)) + "; " + detail);
}
private static ChatColor colorForPrerequisite(boolean met) {
return met ? ChatColor.YELLOW : ChatColor.RED;
}
}
@@ -0,0 +1,310 @@
package games.dmg.spigotbase;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.GameMode;
import org.bukkit.Material;
import org.bukkit.boss.BarColor;
import org.bukkit.boss.BarStyle;
import org.bukkit.boss.BossBar;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.plugin.Plugin;
final class BaseProgressListener implements Listener {
private static final long BOSS_BAR_TICKS = 60L;
private final Plugin plugin;
private final BaseStateManager stateManager;
private final BaseProgressionService baseProgressionService;
private final SecondaryProgressionService secondaryProgressionService;
private final TeleportProgressionService teleportProgressionService;
private final BaseBoundsService boundsService;
private final PluginSettings settings;
private final Map<UUID, BossBar> activeBossBars = new HashMap<>();
BaseProgressListener(
Plugin plugin,
BaseStateManager stateManager,
BaseProgressionService baseProgressionService,
SecondaryProgressionService secondaryProgressionService,
TeleportProgressionService teleportProgressionService,
BaseBoundsService boundsService,
PluginSettings settings
) {
this.plugin = plugin;
this.stateManager = stateManager;
this.baseProgressionService = baseProgressionService;
this.secondaryProgressionService = secondaryProgressionService;
this.teleportProgressionService = teleportProgressionService;
this.boundsService = boundsService;
this.settings = settings;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockBreak(BlockBreakEvent event) {
Player player = event.getPlayer();
Material material = event.getBlock().getType();
if (player.getGameMode() != GameMode.SURVIVAL) {
return;
}
PlayerState before = stateManager.player(player.getUniqueId(), player.getName());
boolean insideBase = isInsideBase(
before,
event.getBlock().getWorld().getUID(),
event.getBlock().getX(),
event.getBlock().getY(),
event.getBlock().getZ()
);
if (!isProgressMaterial(material) && !(insideBase && before.baseLevel() >= 3)) {
return;
}
ProgressionUpdate[] updateHolder = new ProgressionUpdate[1];
PlayerState state = stateManager.update(player.getUniqueId(), player.getName(), current -> {
ProgressionUpdate materialUpdate = updateForMaterial(current, material);
ProgressionUpdate breakUpdate = insideBase
? teleportProgressionService.recordBreak(materialUpdate.player())
: ProgressionUpdate.unchanged(materialUpdate.player());
ProgressionUpdate combined = combine(materialUpdate, breakUpdate);
updateHolder[0] = combined;
return combined.player();
});
ProgressionUpdate update = updateHolder[0];
announceUnlock(player, update);
if (hasUnlock(update)) {
stateManager.saveIfDirty();
}
if (state.bossBarEnabled()) {
if (insideBase && before.baseLevel() >= 3) {
showProgress(player, cooldownDisplay(state));
} else {
showProgress(player, progressDisplay(state, material));
}
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockPlace(BlockPlaceEvent event) {
Player player = event.getPlayer();
if (player.getGameMode() != GameMode.SURVIVAL) {
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;
}
ProgressionUpdate[] updateHolder = new ProgressionUpdate[1];
PlayerState state = stateManager.update(player.getUniqueId(), player.getName(), current -> {
ProgressionUpdate update = teleportProgressionService.recordPlacement(current);
updateHolder[0] = update;
return update.player();
});
ProgressionUpdate update = updateHolder[0];
announceUnlock(player, update);
if (hasUnlock(update)) {
stateManager.saveIfDirty();
}
if (state.bossBarEnabled() && state.baseLevel() >= 2) {
showProgress(player, warmupDisplay(state));
}
}
void removeAllBossBars() {
activeBossBars.values().forEach(BossBar::removeAll);
activeBossBars.clear();
}
private ProgressionUpdate updateForMaterial(PlayerState player, Material material) {
return switch (material) {
case GRASS_BLOCK, DIRT -> baseProgressionService.recordGrassOrDirtBreak(player);
case STONE -> secondaryProgressionService.recordStoneBreak(player);
case DEEPSLATE -> secondaryProgressionService.recordDeepslateBreak(player);
case OBSIDIAN -> secondaryProgressionService.recordObsidianBreak(player);
default -> ProgressionUpdate.unchanged(player);
};
}
private void announceUnlock(Player player, ProgressionUpdate update) {
if (update.unlockedBaseLevel()) {
int level = update.player().baseLevel();
String subtitle = switch (level) {
case 1 -> "/setbase is now available";
case 2 -> "/basenavigation is now available";
case 3 -> "/base is now available";
case 4 -> "Visitors can now travel to your base";
default -> "A new base benefit is available";
};
player.sendTitle(
ChatColor.GOLD + "Base " + roman(level) + " Unlocked",
ChatColor.YELLOW + subtitle,
10, 70, 20
);
player.sendMessage(ChatColor.GREEN + "You unlocked Base " + roman(level) + "! " + subtitle);
}
if (update.unlockedSizeLevel()) {
int radius = boundsService.radius(update.player());
player.sendTitle(
ChatColor.GOLD + "Base Size Upgraded",
ChatColor.YELLOW + "Your base radius is now " + radius + " blocks",
10, 70, 20
);
}
if (update.unlockedWarmupLevel()) {
player.sendTitle(
ChatColor.GOLD + "Teleport Warm-up Improved",
ChatColor.YELLOW + "Your /base warm-up is now shorter",
10, 70, 20
);
}
if (update.unlockedCooldownLevel()) {
player.sendTitle(
ChatColor.GOLD + "Teleport Cooldown Improved",
ChatColor.YELLOW + "You can use /base more often",
10, 70, 20
);
}
}
private void showProgress(Player player, ProgressDisplay display) {
if (display == null) {
removeBossBar(player.getUniqueId());
return;
}
removeBossBar(player.getUniqueId());
BossBar bossBar = Bukkit.createBossBar(
ChatColor.YELLOW + display.label() + ": " + display.count() + "/" + display.threshold(),
BarColor.GREEN,
BarStyle.SOLID
);
bossBar.setProgress(Math.min(1.0, (double) display.count() / display.threshold()));
bossBar.addPlayer(player);
activeBossBars.put(player.getUniqueId(), bossBar);
Bukkit.getScheduler().runTaskLater(plugin, () -> {
if (activeBossBars.remove(player.getUniqueId(), bossBar)) {
bossBar.removeAll();
}
}, BOSS_BAR_TICKS);
}
private ProgressDisplay progressDisplay(PlayerState state, Material material) {
return switch (material) {
case GRASS_BLOCK, DIRT -> state.baseLevel() < 2
? new ProgressDisplay(
state.baseLevel() == 0 ? "Base I" : "Base II",
state.grassAndDirtBroken(),
state.baseLevel() == 0 ? settings.baseUnlockBlocks() : settings.navigationUnlockBlocks()
)
: null;
case STONE -> state.sizeLevel() == 0
? new ProgressDisplay("Base Size II", state.stoneBroken(), settings.stoneExpansionBlocks())
: null;
case DEEPSLATE -> state.sizeLevel() == 1
? new ProgressDisplay("Base Size III", state.deepslateBroken(), settings.deepslateExpansionBlocks())
: null;
case OBSIDIAN -> state.sizeLevel() == 2
? new ProgressDisplay("Base Size IV", state.obsidianBroken(), settings.obsidianExpansionBlocks())
: null;
default -> null;
};
}
private ProgressDisplay warmupDisplay(PlayerState state) {
if (state.baseLevel() == 2) {
return new ProgressDisplay(
"Base III", state.blocksPlacedInBase(), settings.teleportUnlockPlacements()
);
}
return switch (state.warmupLevel()) {
case 0 -> new ProgressDisplay(
"15s Warm-up", state.blocksPlacedInBase(), settings.secondWarmupPlacements()
);
case 1 -> new ProgressDisplay(
"5s Warm-up", state.blocksPlacedInBase(), settings.thirdWarmupPlacements()
);
case 2 -> new ProgressDisplay(
"Instant Warm-up", state.blocksPlacedInBase(), settings.instantWarmupPlacements()
);
default -> null;
};
}
private ProgressDisplay cooldownDisplay(PlayerState state) {
return switch (state.cooldownLevel()) {
case 0 -> new ProgressDisplay(
"2h Cooldown", state.blocksBrokenInBase(), settings.firstCooldownBreaks()
);
case 1 -> new ProgressDisplay(
"1h Cooldown", state.blocksBrokenInBase(), settings.secondCooldownBreaks()
);
case 2 -> new ProgressDisplay(
"30m Cooldown", state.blocksBrokenInBase(), settings.thirdCooldownBreaks()
);
case 3 -> new ProgressDisplay(
"Instant Cooldown", state.blocksBrokenInBase(), settings.instantCooldownBreaks()
);
default -> null;
};
}
private boolean isInsideBase(PlayerState state, UUID worldId, int x, int y, int z) {
return state.base().isPresent() && boundsService.area(state).contains(worldId, x, y, z);
}
private static ProgressionUpdate combine(
ProgressionUpdate first,
ProgressionUpdate second
) {
return new ProgressionUpdate(
second.player(),
first.unlockedBaseLevel() || second.unlockedBaseLevel(),
first.unlockedSizeLevel() || second.unlockedSizeLevel(),
first.unlockedFlightLevel() || second.unlockedFlightLevel(),
first.unlockedWarmupLevel() || second.unlockedWarmupLevel(),
first.unlockedCooldownLevel() || second.unlockedCooldownLevel()
);
}
private static boolean hasUnlock(ProgressionUpdate update) {
return update.unlockedBaseLevel() || update.unlockedSizeLevel()
|| update.unlockedFlightLevel() || update.unlockedWarmupLevel()
|| update.unlockedCooldownLevel();
}
private void removeBossBar(UUID playerId) {
BossBar previous = activeBossBars.remove(playerId);
if (previous != null) {
previous.removeAll();
}
}
private static boolean isProgressMaterial(Material material) {
return material == Material.GRASS_BLOCK || material == Material.DIRT
|| material == Material.STONE || material == Material.DEEPSLATE
|| material == Material.OBSIDIAN;
}
private static String roman(int level) {
return switch (level) {
case 1 -> "I";
case 2 -> "II";
case 3 -> "III";
case 4 -> "IV";
default -> Integer.toString(level);
};
}
private record ProgressDisplay(String label, long count, long threshold) {
}
}
@@ -0,0 +1,29 @@
package games.dmg.spigotbase;
public final class BaseProgressionService {
private final PluginSettings settings;
public BaseProgressionService(PluginSettings settings) {
this.settings = settings;
}
public ProgressionUpdate recordGrassOrDirtBreak(PlayerState player) {
long count = player.grassAndDirtBroken() == Long.MAX_VALUE
? Long.MAX_VALUE
: player.grassAndDirtBroken() + 1;
int previousLevel = player.baseLevel();
int baseLevel = previousLevel;
if (previousLevel == 0 && count >= settings.baseUnlockBlocks()) {
baseLevel = 1;
}
if (previousLevel == 1 && count >= settings.navigationUnlockBlocks()) {
baseLevel = 2;
}
boolean unlocked = baseLevel != previousLevel;
PlayerState updated = player.withGrassAndDirtProgress(count, baseLevel);
if (baseLevel >= 2 && !updated.navigationEnabled()) {
updated = updated.withNavigationEnabled(true);
}
return new ProgressionUpdate(updated, unlocked, false, false, false, false);
}
}
@@ -0,0 +1,39 @@
package games.dmg.spigotbase;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
public final class BaseService {
private final PluginSettings settings;
public BaseService(PluginSettings settings) {
this.settings = settings;
}
public boolean canSetBase(PlayerState player, Instant now) {
if (player.baseLevel() < 1) {
return false;
}
return relocationRemaining(player, now).isEmpty();
}
public Optional<Duration> relocationRemaining(PlayerState player, Instant now) {
if (player.baseLevel() < 1 || player.lastBaseSet().isEmpty()) {
return Optional.empty();
}
Instant availableAt = player.lastBaseSet().orElseThrow()
.plusSeconds(settings.relocationCooldownSeconds());
if (!now.isBefore(availableAt)) {
return Optional.empty();
}
return Optional.of(Duration.between(now, availableAt));
}
public PlayerState setBase(PlayerState player, BaseLocation location, Instant now) {
if (!canSetBase(player, now)) {
throw new IllegalStateException("base cannot be set yet");
}
return player.withBase(location, now);
}
}
@@ -0,0 +1,96 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.UnaryOperator;
import java.util.logging.Level;
import java.util.logging.Logger;
public final class BaseStateManager {
private final YamlBaseStateRepository repository;
private final Logger logger;
private final Map<UUID, PlayerState> players;
private boolean dirty;
public BaseStateManager(YamlBaseStateRepository repository, Logger logger) throws IOException {
this.repository = repository;
this.logger = logger;
this.players = new HashMap<>(repository.load().players());
}
public PlayerState player(UUID playerId, String latestName) {
PlayerState existing = players.get(playerId);
if (existing == null) {
PlayerState created = PlayerState.newPlayer(playerId, latestName);
players.put(playerId, created);
dirty = true;
return created;
}
if (!existing.latestName().equals(latestName)) {
existing = existing.withLatestName(latestName);
players.put(playerId, existing);
dirty = true;
}
return existing;
}
public PlayerState update(UUID playerId, String latestName, UnaryOperator<PlayerState> operation) {
PlayerState updated = operation.apply(player(playerId, latestName));
if (!updated.playerId().equals(playerId)) {
throw new IllegalArgumentException("updated player ID cannot change");
}
players.put(playerId, updated);
dirty = true;
return updated;
}
public PlayerState updateAndSave(
UUID playerId,
String latestName,
UnaryOperator<PlayerState> operation
) throws IOException {
PlayerState current = players.getOrDefault(
playerId,
PlayerState.newPlayer(playerId, latestName)
);
if (!current.latestName().equals(latestName)) {
current = current.withLatestName(latestName);
}
PlayerState updated = operation.apply(current);
if (!updated.playerId().equals(playerId)) {
throw new IllegalArgumentException("updated player ID cannot change");
}
Map<UUID, PlayerState> proposed = new HashMap<>(players);
proposed.put(playerId, updated);
repository.save(new PersistentState(proposed));
players.clear();
players.putAll(proposed);
dirty = false;
return updated;
}
public Map<UUID, PlayerState> knownPlayers() {
return Map.copyOf(players);
}
public Optional<PlayerState> findByName(String name) {
return players.values().stream()
.filter(player -> player.latestName().equalsIgnoreCase(name))
.findFirst();
}
public void saveIfDirty() {
if (!dirty) {
return;
}
try {
repository.save(new PersistentState(players));
dirty = false;
} catch (IOException exception) {
logger.log(Level.SEVERE, "Could not save Spigot Base state", exception);
}
}
}
@@ -0,0 +1,298 @@
package games.dmg.spigotbase;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitTask;
final class BaseTeleportManager implements Listener {
private final Plugin plugin;
private final BaseStateManager stateManager;
private final TeleportPolicy policy;
private final VisitorPolicy visitorPolicy;
private final SafeBaseDestination destinationFinder;
private final Clock clock;
private final Map<UUID, Request> requests = new HashMap<>();
BaseTeleportManager(
Plugin plugin,
BaseStateManager stateManager,
TeleportPolicy policy,
VisitorPolicy visitorPolicy,
SafeBaseDestination destinationFinder,
Clock clock
) {
this.plugin = plugin;
this.stateManager = stateManager;
this.policy = policy;
this.visitorPolicy = visitorPolicy;
this.destinationFinder = destinationFinder;
this.clock = clock;
}
void start(Player player) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (state.baseLevel() < 3 || state.base().isEmpty()) {
player.sendMessage(ChatColor.RED + "Base III teleportation is still locked.");
return;
}
Optional<Duration> remaining = policy.remainingCooldown(state, clock.instant());
if (remaining.isPresent()) {
player.sendMessage(ChatColor.RED + "/base is available again in "
+ DurationFormatter.friendly(remaining.orElseThrow()) + ".");
return;
}
begin(
player,
state.base().orElseThrow(),
policy.warmup(state),
null,
Duration.ZERO,
"return home"
);
}
void startVisit(Player visitor, PlayerState owner) {
if (visitor.getUniqueId().equals(owner.playerId())) {
visitor.sendMessage(ChatColor.RED + "Use /base to visit your own base.");
return;
}
if (owner.baseLevel() < 4 || !owner.visitorsEnabled() || owner.base().isEmpty()) {
visitor.sendMessage(ChatColor.RED + "That base is not accepting visitors.");
return;
}
PlayerState visitorState = stateManager.player(visitor.getUniqueId(), visitor.getName());
Optional<Duration> remaining = visitorPolicy.remaining(
visitorState, owner.playerId(), clock.instant()
);
if (remaining.isPresent()) {
visitor.sendMessage(ChatColor.RED + "You can visit " + owner.latestName() + " again in "
+ DurationFormatter.friendly(remaining.orElseThrow()) + ".");
return;
}
begin(
visitor,
owner.base().orElseThrow(),
policy.warmup(owner),
owner.playerId(),
policy.cooldown(owner),
"visit " + owner.latestName() + "'s base"
);
}
private void begin(
Player player,
BaseLocation destination,
Duration warmup,
UUID visitorOwnerId,
Duration visitorCooldown,
String purpose
) {
if (requests.containsKey(player.getUniqueId())) {
player.sendMessage(ChatColor.RED + "A base teleport is already warming up.");
return;
}
Location origin = player.getLocation();
int seconds = Math.toIntExact(warmup.getSeconds());
Request request = new Request(
origin.getWorld().getUID(),
origin.getBlockX(),
origin.getBlockY(),
origin.getBlockZ(),
seconds,
destination,
visitorOwnerId,
visitorCooldown
);
if (seconds == 0) {
complete(player, request);
return;
}
requests.put(player.getUniqueId(), request);
request.task = Bukkit.getScheduler().runTaskTimer(plugin, () -> tick(player, request), 0L, 20L);
player.sendMessage(ChatColor.YELLOW + "Stand still for " + seconds + " seconds to " + purpose + ".");
}
void cancelAll() {
for (Request request : requests.values()) {
request.cancelTask();
}
requests.clear();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onMove(PlayerMoveEvent event) {
Request request = requests.get(event.getPlayer().getUniqueId());
Location destination = event.getTo();
if (request == null || destination == null) {
return;
}
if (!request.worldId.equals(destination.getWorld().getUID())
|| request.x != destination.getBlockX()
|| request.y != destination.getBlockY()
|| request.z != destination.getBlockZ()) {
cancel(event.getPlayer(), "Base teleport cancelled because you moved.");
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDamage(EntityDamageEvent event) {
Entity entity = event.getEntity();
if (entity instanceof Player player) {
cancel(player, "Base teleport cancelled because you took damage.");
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onTeleport(PlayerTeleportEvent event) {
cancel(event.getPlayer(), "Base teleport cancelled by another teleport.");
}
@EventHandler
public void onWorldChange(PlayerChangedWorldEvent event) {
cancel(event.getPlayer(), "Base teleport cancelled because you changed worlds.");
}
@EventHandler
public void onDeath(PlayerDeathEvent event) {
cancel(event.getEntity(), "Base teleport cancelled because you died.");
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
cancel(event.getPlayer(), null);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onCommand(PlayerCommandPreprocessEvent event) {
String command = event.getMessage().toLowerCase(Locale.ROOT).split("\\s+", 2)[0];
if (command.equals("/base") || command.equals("/gotobase")
|| command.equals("/spawn") || command.equals("/home")
|| command.equals("/tp") || command.equals("/teleport")) {
cancel(event.getPlayer(), "Base teleport cancelled by another teleport command.");
}
}
private void tick(Player player, Request request) {
if (!player.isOnline() || requests.get(player.getUniqueId()) != request) {
request.cancelTask();
return;
}
if (request.remainingSeconds <= 0) {
requests.remove(player.getUniqueId());
request.cancelTask();
complete(player, request);
return;
}
player.sendTitle(
ChatColor.GOLD + Integer.toString(request.remainingSeconds),
ChatColor.YELLOW + "Stand still to return to base",
0, 25, 5
);
request.remainingSeconds--;
}
private void complete(Player player, Request request) {
BaseLocation base = request.destination;
World world = Bukkit.getWorld(base.worldId());
if (world == null) {
player.sendMessage(ChatColor.RED + "The destination world is not currently available.");
return;
}
Optional<Location> destination = destinationFinder.find(world, base);
if (destination.isEmpty()) {
player.sendMessage(ChatColor.RED + "No safe location could be found at the base.");
return;
}
if (!player.teleport(destination.orElseThrow(), PlayerTeleportEvent.TeleportCause.PLUGIN)) {
player.sendMessage(ChatColor.RED + "The base teleport was prevented.");
return;
}
Instant completedAt = clock.instant();
stateManager.update(
player.getUniqueId(),
player.getName(),
current -> request.visitorOwnerId == null
? current.withLastBaseTeleport(completedAt)
: current.withVisitorCooldown(
request.visitorOwnerId,
completedAt.plus(request.visitorCooldown)
)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.GREEN + (request.visitorOwnerId == null
? "Welcome home."
: "Welcome to the base."));
}
private void cancel(Player player, String message) {
Request request = requests.remove(player.getUniqueId());
if (request == null) {
return;
}
request.cancelTask();
if (message != null) {
player.sendMessage(ChatColor.RED + message);
}
}
private static final class Request {
private final UUID worldId;
private final int x;
private final int y;
private final int z;
private final BaseLocation destination;
private final UUID visitorOwnerId;
private final Duration visitorCooldown;
private int remainingSeconds;
private BukkitTask task;
private Request(
UUID worldId,
int x,
int y,
int z,
int remainingSeconds,
BaseLocation destination,
UUID visitorOwnerId,
Duration visitorCooldown
) {
this.worldId = worldId;
this.x = x;
this.y = y;
this.z = z;
this.remainingSeconds = remainingSeconds;
this.destination = destination;
this.visitorOwnerId = visitorOwnerId;
this.visitorCooldown = visitorCooldown;
}
private void cancelTask() {
if (task != null) {
task.cancel();
}
}
}
}
@@ -0,0 +1,38 @@
package games.dmg.spigotbase;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class BaseVisitorsCommand implements CommandExecutor {
private final BaseStateManager stateManager;
BaseVisitorsCommand(BaseStateManager stateManager) {
this.stateManager = stateManager;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can manage base visitors.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (state.baseLevel() < 4) {
player.sendMessage(ChatColor.RED + "Base IV visitor access is still locked.");
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withVisitorsEnabled(!current.visitorsEnabled())
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Visitor teleports are now "
+ (state.visitorsEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
+ ChatColor.YELLOW + ".");
return true;
}
}
@@ -0,0 +1,22 @@
package games.dmg.spigotbase;
import java.time.Duration;
final class DurationFormatter {
private DurationFormatter() {
}
static String friendly(Duration duration) {
long seconds = Math.max(0, duration.getSeconds());
long hours = seconds / 3_600;
long minutes = seconds % 3_600 / 60;
long remainder = seconds % 60;
if (hours > 0) {
return minutes > 0 ? hours + "h " + minutes + "m" : hours + "h";
}
if (minutes > 0) {
return remainder > 0 ? minutes + "m " + remainder + "s" : minutes + "m";
}
return remainder + "s";
}
}
@@ -0,0 +1,61 @@
package games.dmg.spigotbase;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
final class GoToBaseCommand implements CommandExecutor, TabCompleter {
private final BaseStateManager stateManager;
private final BaseTeleportManager teleportManager;
GoToBaseCommand(BaseStateManager stateManager, BaseTeleportManager teleportManager) {
this.stateManager = stateManager;
this.teleportManager = teleportManager;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can visit a base.");
return true;
}
if (arguments.length != 1) {
player.sendMessage(ChatColor.RED + "Usage: /gotobase <player>");
return true;
}
PlayerState owner = stateManager.findByName(arguments[0]).orElse(null);
if (owner == null || owner.baseLevel() < 4 || !owner.visitorsEnabled()
|| owner.base().isEmpty()) {
player.sendMessage(ChatColor.RED + "That player does not have an available base.");
return true;
}
teleportManager.startVisit(player, owner);
return true;
}
@Override
public List<String> onTabComplete(
CommandSender sender,
Command command,
String alias,
String[] arguments
) {
if (!(sender instanceof Player player) || arguments.length != 1) {
return List.of();
}
String prefix = arguments[0].toLowerCase(Locale.ROOT);
return stateManager.knownPlayers().values().stream()
.filter(owner -> !owner.playerId().equals(player.getUniqueId()))
.filter(owner -> owner.baseLevel() >= 4 && owner.visitorsEnabled() && owner.base().isPresent())
.map(PlayerState::latestName)
.filter(name -> name.toLowerCase(Locale.ROOT).startsWith(prefix))
.sorted(Comparator.comparing(String::toLowerCase))
.toList();
}
}
@@ -0,0 +1,14 @@
package games.dmg.spigotbase;
import java.util.Map;
import java.util.UUID;
public record PersistentState(Map<UUID, PlayerState> players) {
public PersistentState {
players = players == null ? Map.of() : Map.copyOf(players);
}
public static PersistentState empty() {
return new PersistentState(Map.of());
}
}
@@ -0,0 +1,278 @@
package games.dmg.spigotbase;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
public record 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
) {
public PlayerState {
if (playerId == null) {
throw new IllegalArgumentException("player ID is required");
}
if (latestName == null || latestName.isBlank()) {
throw new IllegalArgumentException("latest player name is required");
}
base = base == null ? Optional.empty() : base;
lastBaseSet = lastBaseSet == null ? Optional.empty() : lastBaseSet;
lastBaseTeleport = lastBaseTeleport == null ? Optional.empty() : lastBaseTeleport;
visitorCooldownUntil = visitorCooldownUntil == null
? Map.of()
: Map.copyOf(visitorCooldownUntil);
requireLevel(baseLevel, 0, 4, "base level");
requireLevel(sizeLevel, 0, 3, "size level");
requireLevel(flightLevel, 0, 3, "flight level");
requireLevel(warmupLevel, 0, 3, "warm-up level");
requireLevel(cooldownLevel, 0, 4, "cooldown level");
requireNonNegative(grassAndDirtBroken, "grass and dirt broken");
requireNonNegative(stoneBroken, "stone broken");
requireNonNegative(deepslateBroken, "deepslate broken");
requireNonNegative(obsidianBroken, "obsidian broken");
requireNonNegative(blocksPlacedInBase, "blocks placed in base");
requireNonNegative(blocksBrokenInBase, "blocks broken in base");
if (baseLevel == 0 && (sizeLevel > 0 || flightLevel > 0)) {
throw new IllegalArgumentException("secondary progression requires Base I");
}
if (baseLevel < 2 && navigationEnabled) {
throw new IllegalArgumentException("navigation requires Base II");
}
if (flightLevel == 0 && flightEnabled) {
throw new IllegalArgumentException("enabled flight requires an unlocked flight tier");
}
if (baseLevel < 3 && (warmupLevel > 0 || cooldownLevel > 0)) {
throw new IllegalArgumentException("teleport upgrades require Base III");
}
if (baseLevel < 4 && visitorsEnabled) {
throw new IllegalArgumentException("visitor access requires Base IV");
}
if (visitorCooldownUntil.entrySet().stream().anyMatch(entry ->
entry.getKey() == null || entry.getValue() == null)) {
throw new IllegalArgumentException("visitor cooldowns must be complete");
}
}
public static PlayerState newPlayer(UUID playerId, String latestName) {
return new PlayerState(
playerId,
latestName,
Optional.empty(),
0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
false, false, true, false,
Optional.empty(), Optional.empty(), Map.of()
);
}
public PlayerState withLatestName(String name) {
return new PlayerState(
playerId, name, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
);
}
public PlayerState withGrassAndDirtProgress(long count, int newBaseLevel) {
return new PlayerState(
playerId, latestName, base, newBaseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, count, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
);
}
public PlayerState withBossBarEnabled(boolean enabled) {
return new PlayerState(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, enabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
);
}
public PlayerState withNavigationEnabled(boolean enabled) {
return new PlayerState(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
enabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
);
}
public PlayerState withSizeProgress(long stone, long deepslate, long obsidian, int level) {
return new PlayerState(
playerId, latestName, base, baseLevel, level, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stone, deepslate,
obsidian, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
);
}
public PlayerState withFlightLevel(int level, boolean enabled) {
return new PlayerState(
playerId, latestName, base, baseLevel, sizeLevel, level,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, enabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
);
}
public PlayerState withFlightEnabled(boolean enabled) {
return withFlightLevel(flightLevel, enabled);
}
public PlayerState withTeleportProgress(
long placements,
long breaks,
int newWarmupLevel,
int newCooldownLevel,
int newBaseLevel
) {
return new PlayerState(
playerId, latestName, base, newBaseLevel, sizeLevel, flightLevel,
newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, placements, breaks,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
);
}
public PlayerState withTeleportLevels(int newWarmupLevel, int newCooldownLevel) {
return withTeleportProgress(
blocksPlacedInBase, blocksBrokenInBase, newWarmupLevel, newCooldownLevel, baseLevel
);
}
public PlayerState withLastBaseTeleport(Instant usedAt) {
return new PlayerState(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, Optional.of(usedAt), visitorCooldownUntil
);
}
public PlayerState withBaseLevel(int level) {
return new PlayerState(
playerId, latestName, base, level, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
);
}
public PlayerState withAdministrativeLevels(
int newBaseLevel,
int newSizeLevel,
int newFlightLevel,
int newWarmupLevel,
int newCooldownLevel,
boolean newNavigationEnabled,
boolean newFlightEnabled,
boolean newVisitorsEnabled
) {
return new PlayerState(
playerId, latestName, base, newBaseLevel, newSizeLevel, newFlightLevel,
newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
newNavigationEnabled, newFlightEnabled, bossBarEnabled, newVisitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
);
}
public PlayerState withVisitorsEnabled(boolean enabled) {
return new PlayerState(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, enabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
);
}
public PlayerState withVisitorCooldown(UUID ownerId, Instant availableAt) {
java.util.HashMap<UUID, Instant> cooldowns = new java.util.HashMap<>(visitorCooldownUntil);
cooldowns.put(ownerId, availableAt);
return new PlayerState(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, cooldowns
);
}
public PlayerState withoutPersonalCooldown() {
return new PlayerState(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, Optional.empty(), visitorCooldownUntil
);
}
public PlayerState withoutVisitorCooldowns() {
return new PlayerState(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, Map.of()
);
}
public PlayerState withBase(BaseLocation location, Instant setAt) {
return new PlayerState(
playerId, latestName, Optional.of(location), baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
Optional.of(setAt), lastBaseTeleport, visitorCooldownUntil
);
}
private static void requireLevel(int value, int minimum, int maximum, String name) {
if (value < minimum || value > maximum) {
throw new IllegalArgumentException(name + " must be between " + minimum + " and " + maximum);
}
}
private static void requireNonNegative(long value, String name) {
if (value < 0) {
throw new IllegalArgumentException(name + " must not be negative");
}
}
}
@@ -0,0 +1,210 @@
package games.dmg.spigotbase;
import java.util.Map;
import java.util.Objects;
public record PluginSettings(
int baseUnlockBlocks,
int navigationUnlockBlocks,
int initialRadius,
int initialVerticalRange,
int stoneExpansionBlocks,
int deepslateExpansionBlocks,
int obsidianExpansionBlocks,
int firstExpandedRadius,
int secondExpandedRadius,
int thirdExpandedRadius,
long relocationCooldownSeconds,
int flightWarningBuffer,
int secondFlightVerticalRange,
int teleportUnlockPlacements,
int secondWarmupPlacements,
int thirdWarmupPlacements,
int instantWarmupPlacements,
int initialWarmupSeconds,
int secondWarmupSeconds,
int thirdWarmupSeconds,
int firstCooldownBreaks,
int secondCooldownBreaks,
int thirdCooldownBreaks,
int instantCooldownBreaks,
long initialTeleportCooldownSeconds,
long secondTeleportCooldownSeconds,
long thirdTeleportCooldownSeconds,
long fourthTeleportCooldownSeconds,
int visitorUnlockDiamondCost
) {
private static final int DEFAULT_BASE_UNLOCK_BLOCKS = 250;
private static final int DEFAULT_NAVIGATION_UNLOCK_BLOCKS = 500;
private static final int DEFAULT_INITIAL_RADIUS = 10;
private static final int DEFAULT_INITIAL_VERTICAL_RANGE = 25;
private static final int DEFAULT_STONE_EXPANSION_BLOCKS = 500;
private static final int DEFAULT_DEEPSLATE_EXPANSION_BLOCKS = 1_000;
private static final int DEFAULT_OBSIDIAN_EXPANSION_BLOCKS = 1_000;
private static final int DEFAULT_FIRST_EXPANDED_RADIUS = 25;
private static final int DEFAULT_SECOND_EXPANDED_RADIUS = 75;
private static final int DEFAULT_THIRD_EXPANDED_RADIUS = 150;
private static final long DEFAULT_RELOCATION_COOLDOWN_SECONDS = 86_400L;
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_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;
private static final int DEFAULT_INITIAL_WARMUP_SECONDS = 30;
private static final int DEFAULT_SECOND_WARMUP_SECONDS = 15;
private static final int DEFAULT_THIRD_WARMUP_SECONDS = 5;
private static final int DEFAULT_FIRST_COOLDOWN_BREAKS = 1_000;
private static final int DEFAULT_SECOND_COOLDOWN_BREAKS = 2_000;
private static final int DEFAULT_THIRD_COOLDOWN_BREAKS = 3_000;
private static final int DEFAULT_INSTANT_COOLDOWN_BREAKS = 5_000;
private static final long DEFAULT_INITIAL_TELEPORT_COOLDOWN_SECONDS = 10_800L;
private static final long DEFAULT_SECOND_TELEPORT_COOLDOWN_SECONDS = 7_200L;
private static final long DEFAULT_THIRD_TELEPORT_COOLDOWN_SECONDS = 3_600L;
private static final long DEFAULT_FOURTH_TELEPORT_COOLDOWN_SECONDS = 1_800L;
private static final int DEFAULT_VISITOR_UNLOCK_DIAMOND_COST = 128;
public PluginSettings {
requirePositive(baseUnlockBlocks, "base-unlock-blocks");
requirePositive(navigationUnlockBlocks, "navigation-unlock-blocks");
if (navigationUnlockBlocks < baseUnlockBlocks) {
throw new IllegalArgumentException(
"navigation-unlock-blocks must be at least base-unlock-blocks"
);
}
requirePositive(initialRadius, "initial-radius");
requirePositive(initialVerticalRange, "initial-vertical-range");
requirePositive(stoneExpansionBlocks, "stone-expansion-blocks");
requirePositive(deepslateExpansionBlocks, "deepslate-expansion-blocks");
requirePositive(obsidianExpansionBlocks, "obsidian-expansion-blocks");
requirePositive(firstExpandedRadius, "first-expanded-radius");
requirePositive(secondExpandedRadius, "second-expanded-radius");
requirePositive(thirdExpandedRadius, "third-expanded-radius");
if (firstExpandedRadius <= initialRadius
|| secondExpandedRadius <= firstExpandedRadius
|| thirdExpandedRadius <= secondExpandedRadius) {
throw new IllegalArgumentException("expanded radii must increase at each tier");
}
requireNonNegative(relocationCooldownSeconds, "relocation-cooldown-seconds");
requireNonNegative(flightWarningBuffer, "flight-warning-buffer");
if (secondFlightVerticalRange <= initialVerticalRange) {
throw new IllegalArgumentException("second-flight-vertical-range must exceed the initial range");
}
requirePositive(teleportUnlockPlacements, "teleport-unlock-placements");
if (secondWarmupPlacements <= teleportUnlockPlacements
|| thirdWarmupPlacements <= secondWarmupPlacements
|| instantWarmupPlacements <= thirdWarmupPlacements) {
throw new IllegalArgumentException("warm-up placement thresholds must increase");
}
requirePositive(initialWarmupSeconds, "initial-warmup-seconds");
requirePositive(secondWarmupSeconds, "second-warmup-seconds");
requirePositive(thirdWarmupSeconds, "third-warmup-seconds");
if (secondWarmupSeconds >= initialWarmupSeconds
|| thirdWarmupSeconds >= secondWarmupSeconds) {
throw new IllegalArgumentException("warm-up durations must decrease");
}
if (firstCooldownBreaks <= 0 || secondCooldownBreaks <= firstCooldownBreaks
|| thirdCooldownBreaks <= secondCooldownBreaks
|| instantCooldownBreaks <= thirdCooldownBreaks) {
throw new IllegalArgumentException("cooldown break thresholds must increase");
}
requireNonNegative(initialTeleportCooldownSeconds, "initial-teleport-cooldown-seconds");
requireNonNegative(secondTeleportCooldownSeconds, "second-teleport-cooldown-seconds");
requireNonNegative(thirdTeleportCooldownSeconds, "third-teleport-cooldown-seconds");
requireNonNegative(fourthTeleportCooldownSeconds, "fourth-teleport-cooldown-seconds");
if (secondTeleportCooldownSeconds >= initialTeleportCooldownSeconds
|| thirdTeleportCooldownSeconds >= secondTeleportCooldownSeconds
|| fourthTeleportCooldownSeconds >= thirdTeleportCooldownSeconds) {
throw new IllegalArgumentException("teleport cooldown durations must decrease");
}
requirePositive(visitorUnlockDiamondCost, "visitor-unlock-diamond-cost");
}
public static PluginSettings from(Map<String, ?> values) {
Objects.requireNonNull(values, "values");
return new PluginSettings(
integer(values, "base-unlock-blocks", DEFAULT_BASE_UNLOCK_BLOCKS),
integer(values, "navigation-unlock-blocks", DEFAULT_NAVIGATION_UNLOCK_BLOCKS),
integer(values, "initial-radius", DEFAULT_INITIAL_RADIUS),
integer(values, "initial-vertical-range", DEFAULT_INITIAL_VERTICAL_RANGE),
integer(values, "stone-expansion-blocks", DEFAULT_STONE_EXPANSION_BLOCKS),
integer(values, "deepslate-expansion-blocks", DEFAULT_DEEPSLATE_EXPANSION_BLOCKS),
integer(values, "obsidian-expansion-blocks", DEFAULT_OBSIDIAN_EXPANSION_BLOCKS),
integer(values, "first-expanded-radius", DEFAULT_FIRST_EXPANDED_RADIUS),
integer(values, "second-expanded-radius", DEFAULT_SECOND_EXPANDED_RADIUS),
integer(values, "third-expanded-radius", DEFAULT_THIRD_EXPANDED_RADIUS),
longInteger(values, "relocation-cooldown-seconds", DEFAULT_RELOCATION_COOLDOWN_SECONDS),
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, "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),
integer(values, "initial-warmup-seconds", DEFAULT_INITIAL_WARMUP_SECONDS),
integer(values, "second-warmup-seconds", DEFAULT_SECOND_WARMUP_SECONDS),
integer(values, "third-warmup-seconds", DEFAULT_THIRD_WARMUP_SECONDS),
integer(values, "first-cooldown-breaks", DEFAULT_FIRST_COOLDOWN_BREAKS),
integer(values, "second-cooldown-breaks", DEFAULT_SECOND_COOLDOWN_BREAKS),
integer(values, "third-cooldown-breaks", DEFAULT_THIRD_COOLDOWN_BREAKS),
integer(values, "instant-cooldown-breaks", DEFAULT_INSTANT_COOLDOWN_BREAKS),
longInteger(
values,
"initial-teleport-cooldown-seconds",
DEFAULT_INITIAL_TELEPORT_COOLDOWN_SECONDS
),
longInteger(
values,
"second-teleport-cooldown-seconds",
DEFAULT_SECOND_TELEPORT_COOLDOWN_SECONDS
),
longInteger(
values,
"third-teleport-cooldown-seconds",
DEFAULT_THIRD_TELEPORT_COOLDOWN_SECONDS
),
longInteger(
values,
"fourth-teleport-cooldown-seconds",
DEFAULT_FOURTH_TELEPORT_COOLDOWN_SECONDS
),
integer(values, "visitor-unlock-diamond-cost", DEFAULT_VISITOR_UNLOCK_DIAMOND_COST)
);
}
private static int integer(Map<String, ?> values, String key, int defaultValue) {
long value = longInteger(values, key, defaultValue);
if (value > Integer.MAX_VALUE || value < Integer.MIN_VALUE) {
throw new IllegalArgumentException(key + " must be a 32-bit integer");
}
return (int) value;
}
private static long longInteger(Map<String, ?> values, String key, long defaultValue) {
Object value = values.get(key);
if (value == null) {
return defaultValue;
}
if (!(value instanceof Number number)) {
throw new IllegalArgumentException(key + " must be an integer");
}
if (number instanceof Float || number instanceof Double) {
double decimal = number.doubleValue();
if (!Double.isFinite(decimal) || decimal != Math.rint(decimal)) {
throw new IllegalArgumentException(key + " must be an integer");
}
}
return number.longValue();
}
private static void requirePositive(long value, String name) {
if (value <= 0) {
throw new IllegalArgumentException(name + " must be positive");
}
}
private static void requireNonNegative(long value, String name) {
if (value < 0) {
throw new IllegalArgumentException(name + " must not be negative");
}
}
}
@@ -0,0 +1,9 @@
package games.dmg.spigotbase;
public enum ProgressionPath {
BASE,
SIZE,
FLIGHT,
WARMUP,
COOLDOWN
}
@@ -0,0 +1,20 @@
package games.dmg.spigotbase;
public record ProgressionUpdate(
PlayerState player,
boolean unlockedBaseLevel,
boolean unlockedSizeLevel,
boolean unlockedFlightLevel,
boolean unlockedWarmupLevel,
boolean unlockedCooldownLevel
) {
public ProgressionUpdate {
if (player == null) {
throw new IllegalArgumentException("player is required");
}
}
public static ProgressionUpdate unchanged(PlayerState player) {
return new ProgressionUpdate(player, false, false, false, false, false);
}
}
@@ -0,0 +1,78 @@
package games.dmg.spigotbase;
import java.util.Optional;
import java.util.Set;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
final class SafeBaseDestination {
private static final int HORIZONTAL_SEARCH_RADIUS = 5;
private static final int VERTICAL_SEARCH_RADIUS = 4;
private static final Set<Material> HAZARDS = Set.of(
Material.LAVA,
Material.FIRE,
Material.SOUL_FIRE,
Material.MAGMA_BLOCK,
Material.CACTUS,
Material.CAMPFIRE,
Material.SOUL_CAMPFIRE,
Material.POWDER_SNOW
);
Optional<Location> find(World world, BaseLocation base) {
for (int radius = 0; radius <= HORIZONTAL_SEARCH_RADIUS; radius++) {
for (int deltaX = -radius; deltaX <= radius; deltaX++) {
for (int deltaZ = -radius; deltaZ <= radius; deltaZ++) {
if (radius > 0 && Math.abs(deltaX) != radius && Math.abs(deltaZ) != radius) {
continue;
}
for (int vertical = 0; vertical <= VERTICAL_SEARCH_RADIUS; vertical++) {
Optional<Location> above = candidate(
world, base, deltaX, vertical, deltaZ
);
if (above.isPresent()) {
return above;
}
if (vertical > 0) {
Optional<Location> below = candidate(
world, base, deltaX, -vertical, deltaZ
);
if (below.isPresent()) {
return below;
}
}
}
}
}
}
return Optional.empty();
}
private Optional<Location> candidate(
World world,
BaseLocation base,
int deltaX,
int deltaY,
int deltaZ
) {
int x = base.x() + deltaX;
int y = base.y() + deltaY;
int z = base.z() + deltaZ;
if (y <= world.getMinHeight() || y + 1 >= world.getMaxHeight()) {
return Optional.empty();
}
Block feet = world.getBlockAt(x, y, z);
Block head = world.getBlockAt(x, y + 1, z);
Block ground = world.getBlockAt(x, y - 1, z);
if (!feet.isPassable() || !head.isPassable() || !ground.getType().isSolid()
|| feet.isLiquid() || head.isLiquid() || HAZARDS.contains(ground.getType())
|| HAZARDS.contains(feet.getType()) || HAZARDS.contains(head.getType())) {
return Optional.empty();
}
return Optional.of(new Location(
world, x + 0.5, y, z + 0.5, base.yaw(), base.pitch()
));
}
}
@@ -0,0 +1,67 @@
package games.dmg.spigotbase;
public final class SecondaryProgressionService {
private final PluginSettings settings;
public SecondaryProgressionService(PluginSettings settings) {
this.settings = settings;
}
public ProgressionUpdate recordStoneBreak(PlayerState player) {
if (player.baseLevel() < 1 || player.sizeLevel() != 0) {
return ProgressionUpdate.unchanged(player);
}
long count = increment(player.stoneBroken());
boolean unlocked = count >= settings.stoneExpansionBlocks();
return new ProgressionUpdate(
player.withSizeProgress(
count, player.deepslateBroken(), player.obsidianBroken(), unlocked ? 1 : 0
),
false, unlocked, false, false, false
);
}
public ProgressionUpdate recordDeepslateBreak(PlayerState player) {
if (player.baseLevel() < 1 || player.sizeLevel() != 1) {
return ProgressionUpdate.unchanged(player);
}
long count = increment(player.deepslateBroken());
boolean unlocked = count >= settings.deepslateExpansionBlocks();
return new ProgressionUpdate(
player.withSizeProgress(
player.stoneBroken(), count, player.obsidianBroken(), unlocked ? 2 : 1
),
false, unlocked, false, false, false
);
}
public ProgressionUpdate recordObsidianBreak(PlayerState player) {
if (player.baseLevel() < 1 || player.sizeLevel() != 2) {
return ProgressionUpdate.unchanged(player);
}
long count = increment(player.obsidianBroken());
boolean unlocked = count >= settings.obsidianExpansionBlocks();
return new ProgressionUpdate(
player.withSizeProgress(
player.stoneBroken(), player.deepslateBroken(), count, unlocked ? 3 : 2
),
false, unlocked, false, false, false
);
}
public ProgressionUpdate observeElytraCount(PlayerState player, int elytraCount) {
if (player.baseLevel() < 1 || player.base().isEmpty()
|| elytraCount <= player.flightLevel()) {
return ProgressionUpdate.unchanged(player);
}
int level = Math.min(3, Math.max(0, elytraCount));
return new ProgressionUpdate(
player.withFlightLevel(level, true),
false, false, true, false, false
);
}
private static long increment(long value) {
return value == Long.MAX_VALUE ? Long.MAX_VALUE : value + 1;
}
}
@@ -0,0 +1,61 @@
package games.dmg.spigotbase;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class SetBaseCommand implements CommandExecutor {
private final BaseStateManager stateManager;
private final BaseService baseService;
private final Clock clock;
SetBaseCommand(BaseStateManager stateManager, BaseService baseService, Clock clock) {
this.stateManager = stateManager;
this.baseService = baseService;
this.clock = clock;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can set a base.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (state.baseLevel() < 1) {
player.sendMessage(ChatColor.RED + "Base I is locked. Break grass blocks or dirt to unlock it.");
return true;
}
Instant now = clock.instant();
Optional<Duration> remaining = baseService.relocationRemaining(state, now);
if (remaining.isPresent()) {
player.sendMessage(ChatColor.RED + "You can move your base again in "
+ DurationFormatter.friendly(remaining.orElseThrow()) + ".");
return true;
}
Location location = player.getLocation();
World world = location.getWorld();
if (world == null) {
player.sendMessage(ChatColor.RED + "Your current world is unavailable.");
return true;
}
BaseLocation base = new BaseLocation(
world.getUID(), world.getName(), location.getBlockX(), location.getBlockY(),
location.getBlockZ(), location.getYaw(), location.getPitch()
);
stateManager.update(
player.getUniqueId(), player.getName(), current -> baseService.setBase(current, base, now)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.GREEN + "Base set at " + base.x() + ", " + base.y() + ", " + base.z() + ".");
return true;
}
}
@@ -0,0 +1,114 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.time.Clock;
import java.util.Map;
import java.util.Objects;
import java.util.logging.Level;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin;
public final class SpigotBasePlugin extends JavaPlugin {
private PluginSettings settings;
private BaseStateManager stateManager;
private BaseProgressListener progressListener;
private BaseFlightController flightController;
private BaseTeleportManager teleportManager;
@Override
public void onEnable() {
saveDefaultConfig();
try {
Map<String, Object> values = getConfig().getValues(false);
settings = PluginSettings.from(values);
stateManager = new BaseStateManager(
new YamlBaseStateRepository(getDataFolder().toPath().resolve("state.yml")),
getLogger()
);
} catch (IllegalArgumentException | IOException exception) {
getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception);
getServer().getPluginManager().disablePlugin(this);
return;
}
BaseService baseService = new BaseService(settings);
BaseProgressionService progressionService = new BaseProgressionService(settings);
SecondaryProgressionService secondaryProgressionService =
new SecondaryProgressionService(settings);
TeleportProgressionService teleportProgressionService =
new TeleportProgressionService(settings);
BaseBoundsService boundsService = new BaseBoundsService(settings);
progressListener = new BaseProgressListener(
this,
stateManager,
progressionService,
secondaryProgressionService,
teleportProgressionService,
boundsService,
settings
);
flightController = new BaseFlightController(
getServer(), stateManager, secondaryProgressionService, boundsService, settings
);
VisitorPolicy visitorPolicy = new VisitorPolicy();
teleportManager = new BaseTeleportManager(
this,
stateManager,
new TeleportPolicy(settings),
visitorPolicy,
new SafeBaseDestination(),
Clock.systemUTC()
);
getServer().getPluginManager().registerEvents(progressListener, this);
getServer().getPluginManager().registerEvents(teleportManager, this);
command("setbase").setExecutor(new SetBaseCommand(stateManager, baseService, Clock.systemUTC()));
command("base").setExecutor(
new BaseCommand(teleportManager, stateManager, visitorPolicy, settings)
);
command("baseprogress").setExecutor(new BaseProgressCommand(stateManager, settings));
command("basenavigation").setExecutor(new BaseNavigationCommand(stateManager));
command("baseflight").setExecutor(new BaseFlightCommand(stateManager, flightController));
command("basevisitors").setExecutor(new BaseVisitorsCommand(stateManager));
GoToBaseCommand goToBaseCommand = new GoToBaseCommand(stateManager, teleportManager);
command("gotobase").setExecutor(goToBaseCommand);
command("gotobase").setTabCompleter(goToBaseCommand);
command("baseadmin").setExecutor(
new BaseAdminCommand(stateManager, new AdminProgressionService())
);
getServer().getScheduler().runTaskTimer(
this, new BaseNavigationController(getServer(), stateManager), 10L, 10L
);
getServer().getScheduler().runTaskTimer(this, flightController, 5L, 5L);
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
getLogger().info("Spigot Base enabled.");
}
@Override
public void onDisable() {
if (progressListener != null) {
progressListener.removeAllBossBars();
}
if (flightController != null) {
flightController.removeAllGrantedFlight();
}
if (teleportManager != null) {
teleportManager.cancelAll();
}
if (stateManager != null) {
stateManager.saveIfDirty();
}
}
PluginSettings settings() {
if (settings == null) {
throw new IllegalStateException("Plugin settings are unavailable");
}
return settings;
}
private PluginCommand command(String name) {
return Objects.requireNonNull(getCommand(name), "Missing command metadata for " + name);
}
}
@@ -0,0 +1,45 @@
package games.dmg.spigotbase;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
public final class TeleportPolicy {
private final PluginSettings settings;
public TeleportPolicy(PluginSettings settings) {
this.settings = settings;
}
public Duration warmup(PlayerState player) {
return switch (player.warmupLevel()) {
case 0 -> Duration.ofSeconds(settings.initialWarmupSeconds());
case 1 -> Duration.ofSeconds(settings.secondWarmupSeconds());
case 2 -> Duration.ofSeconds(settings.thirdWarmupSeconds());
case 3 -> Duration.ZERO;
default -> throw new IllegalArgumentException("unknown warm-up level");
};
}
public Duration cooldown(PlayerState player) {
return switch (player.cooldownLevel()) {
case 0 -> Duration.ofSeconds(settings.initialTeleportCooldownSeconds());
case 1 -> Duration.ofSeconds(settings.secondTeleportCooldownSeconds());
case 2 -> Duration.ofSeconds(settings.thirdTeleportCooldownSeconds());
case 3 -> Duration.ofSeconds(settings.fourthTeleportCooldownSeconds());
case 4 -> Duration.ZERO;
default -> throw new IllegalArgumentException("unknown cooldown level");
};
}
public Optional<Duration> remainingCooldown(PlayerState player, Instant now) {
if (player.lastBaseTeleport().isEmpty()) {
return Optional.empty();
}
Instant availableAt = player.lastBaseTeleport().orElseThrow().plus(cooldown(player));
if (!now.isBefore(availableAt)) {
return Optional.empty();
}
return Optional.of(Duration.between(now, availableAt));
}
}
@@ -0,0 +1,90 @@
package games.dmg.spigotbase;
public final class TeleportProgressionService {
private final PluginSettings settings;
public TeleportProgressionService(PluginSettings settings) {
this.settings = settings;
}
public ProgressionUpdate recordPlacement(PlayerState player) {
if (player.baseLevel() < 2 || player.base().isEmpty()) {
return ProgressionUpdate.unchanged(player);
}
long placements = increment(player.blocksPlacedInBase());
int baseLevel = player.baseLevel();
if (baseLevel == 2 && placements >= settings.teleportUnlockPlacements()) {
baseLevel = 3;
}
int warmupLevel = baseLevel >= 3 ? warmupLevel(placements) : 0;
return new ProgressionUpdate(
player.withTeleportProgress(
placements,
player.blocksBrokenInBase(),
warmupLevel,
player.cooldownLevel(),
baseLevel
),
baseLevel > player.baseLevel(),
false,
false,
warmupLevel > player.warmupLevel(),
false
);
}
public ProgressionUpdate recordBreak(PlayerState player) {
if (player.baseLevel() < 3 || player.base().isEmpty()) {
return ProgressionUpdate.unchanged(player);
}
long breaks = increment(player.blocksBrokenInBase());
int cooldownLevel = cooldownLevel(breaks);
return new ProgressionUpdate(
player.withTeleportProgress(
player.blocksPlacedInBase(),
breaks,
player.warmupLevel(),
cooldownLevel,
player.baseLevel()
),
false,
false,
false,
false,
cooldownLevel > player.cooldownLevel()
);
}
private int warmupLevel(long placements) {
if (placements >= settings.instantWarmupPlacements()) {
return 3;
}
if (placements >= settings.thirdWarmupPlacements()) {
return 2;
}
if (placements >= settings.secondWarmupPlacements()) {
return 1;
}
return 0;
}
private int cooldownLevel(long breaks) {
if (breaks >= settings.instantCooldownBreaks()) {
return 4;
}
if (breaks >= settings.thirdCooldownBreaks()) {
return 3;
}
if (breaks >= settings.secondCooldownBreaks()) {
return 2;
}
if (breaks >= settings.firstCooldownBreaks()) {
return 1;
}
return 0;
}
private static long increment(long value) {
return value == Long.MAX_VALUE ? Long.MAX_VALUE : value + 1;
}
}
@@ -0,0 +1,20 @@
package games.dmg.spigotbase;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
public final class VisitorPolicy {
public boolean canPurchase(PlayerState owner) {
return owner.baseLevel() == 3 && owner.base().isPresent();
}
public Optional<Duration> remaining(PlayerState visitor, UUID ownerId, Instant now) {
Instant availableAt = visitor.visitorCooldownUntil().get(ownerId);
if (availableAt == null || !now.isBefore(availableAt)) {
return Optional.empty();
}
return Optional.of(Duration.between(now, availableAt));
}
}
@@ -0,0 +1,218 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
public final class YamlBaseStateRepository {
private final Path stateFile;
public YamlBaseStateRepository(Path stateFile) {
this.stateFile = stateFile;
}
public PersistentState load() throws IOException {
if (!Files.exists(stateFile)) {
return PersistentState.empty();
}
YamlConfiguration yaml = new YamlConfiguration();
try {
yaml.load(stateFile.toFile());
} catch (InvalidConfigurationException exception) {
throw new IOException("state file is not valid YAML", exception);
}
return new PersistentState(loadPlayers(yaml));
}
public void save(PersistentState state) throws IOException {
Path parent = stateFile.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
YamlConfiguration yaml = new YamlConfiguration();
savePlayers(yaml, state.players());
Path temporary = Files.createTempFile(parent, "spigot-base-state-", ".yml");
try {
yaml.save(temporary.toFile());
try {
Files.move(
temporary,
stateFile,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE
);
} catch (IOException atomicMoveFailure) {
Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(temporary);
}
}
private static Map<UUID, PlayerState> loadPlayers(YamlConfiguration yaml) {
Map<UUID, PlayerState> players = new HashMap<>();
ConfigurationSection section = yaml.getConfigurationSection("players");
if (section == null) {
return players;
}
for (String key : section.getKeys(false)) {
try {
UUID playerId = UUID.fromString(key);
String path = "players." + key;
String name = yaml.getString(path + ".name");
if (name == null || name.isBlank()) {
continue;
}
PlayerState player = new PlayerState(
playerId,
name,
loadBase(yaml, path + ".base"),
level(yaml, path + ".base-level"),
level(yaml, path + ".size-level"),
level(yaml, path + ".flight-level"),
level(yaml, path + ".warmup-level"),
level(yaml, path + ".cooldown-level"),
count(yaml, path + ".grass-and-dirt-broken"),
count(yaml, path + ".stone-broken"),
count(yaml, path + ".deepslate-broken"),
count(yaml, path + ".obsidian-broken"),
count(yaml, path + ".blocks-placed-in-base"),
count(yaml, path + ".blocks-broken-in-base"),
yaml.getBoolean(path + ".navigation-enabled", false),
yaml.getBoolean(path + ".flight-enabled", false),
yaml.getBoolean(path + ".boss-bar-enabled", true),
yaml.getBoolean(path + ".visitors-enabled", false),
instant(yaml, path + ".last-base-set-epoch-millis"),
instant(yaml, path + ".last-base-teleport-epoch-millis"),
loadVisitorCooldowns(yaml, path + ".visitor-cooldowns")
);
players.put(playerId, player);
} catch (IllegalArgumentException ignored) {
// Invalid records are ignored rather than granting progression.
}
}
return players;
}
private static Optional<BaseLocation> loadBase(YamlConfiguration yaml, String path) {
if (!yaml.isConfigurationSection(path)) {
return Optional.empty();
}
String worldId = yaml.getString(path + ".world-id");
String worldName = yaml.getString(path + ".world-name");
if (worldId == null || worldName == null
|| !yaml.isInt(path + ".x") || !yaml.isInt(path + ".y") || !yaml.isInt(path + ".z")
|| !yaml.isDouble(path + ".yaw") || !yaml.isDouble(path + ".pitch")) {
throw new IllegalArgumentException("invalid base location");
}
return Optional.of(new BaseLocation(
UUID.fromString(worldId),
worldName,
yaml.getInt(path + ".x"),
yaml.getInt(path + ".y"),
yaml.getInt(path + ".z"),
(float) yaml.getDouble(path + ".yaw"),
(float) yaml.getDouble(path + ".pitch")
));
}
private static Map<UUID, Instant> loadVisitorCooldowns(YamlConfiguration yaml, String path) {
Map<UUID, Instant> cooldowns = new HashMap<>();
ConfigurationSection section = yaml.getConfigurationSection(path);
if (section == null) {
return cooldowns;
}
for (String key : section.getKeys(false)) {
try {
if (yaml.isLong(path + "." + key)) {
long millis = yaml.getLong(path + "." + key);
if (millis >= 0) {
cooldowns.put(UUID.fromString(key), Instant.ofEpochMilli(millis));
}
}
} catch (IllegalArgumentException ignored) {
// Invalid destination cooldowns grant no state.
}
}
return cooldowns;
}
private static int level(YamlConfiguration yaml, String path) {
if (!yaml.isInt(path)) {
return 0;
}
return yaml.getInt(path);
}
private static long count(YamlConfiguration yaml, String path) {
if (!yaml.isLong(path) && !yaml.isInt(path)) {
return 0;
}
return yaml.getLong(path);
}
private static Optional<Instant> instant(YamlConfiguration yaml, String path) {
if (!yaml.isLong(path) && !yaml.isInt(path)) {
return Optional.empty();
}
long millis = yaml.getLong(path);
return millis < 0 ? Optional.empty() : Optional.of(Instant.ofEpochMilli(millis));
}
private static void savePlayers(YamlConfiguration yaml, Map<UUID, PlayerState> players) {
for (PlayerState player : players.values()) {
String path = "players." + player.playerId();
yaml.set(path + ".name", player.latestName());
player.base().ifPresent(base -> saveBase(yaml, path + ".base", base));
yaml.set(path + ".base-level", player.baseLevel());
yaml.set(path + ".size-level", player.sizeLevel());
yaml.set(path + ".flight-level", player.flightLevel());
yaml.set(path + ".warmup-level", player.warmupLevel());
yaml.set(path + ".cooldown-level", player.cooldownLevel());
yaml.set(path + ".grass-and-dirt-broken", player.grassAndDirtBroken());
yaml.set(path + ".stone-broken", player.stoneBroken());
yaml.set(path + ".deepslate-broken", player.deepslateBroken());
yaml.set(path + ".obsidian-broken", player.obsidianBroken());
yaml.set(path + ".blocks-placed-in-base", player.blocksPlacedInBase());
yaml.set(path + ".blocks-broken-in-base", player.blocksBrokenInBase());
yaml.set(path + ".navigation-enabled", player.navigationEnabled());
yaml.set(path + ".flight-enabled", player.flightEnabled());
yaml.set(path + ".boss-bar-enabled", player.bossBarEnabled());
yaml.set(path + ".visitors-enabled", player.visitorsEnabled());
yaml.set(
path + ".last-base-set-epoch-millis",
player.lastBaseSet().map(Instant::toEpochMilli).orElse(null)
);
yaml.set(
path + ".last-base-teleport-epoch-millis",
player.lastBaseTeleport().map(Instant::toEpochMilli).orElse(null)
);
for (Map.Entry<UUID, Instant> cooldown : player.visitorCooldownUntil().entrySet()) {
yaml.set(
path + ".visitor-cooldowns." + cooldown.getKey(),
cooldown.getValue().toEpochMilli()
);
}
}
}
private static void saveBase(YamlConfiguration yaml, String path, BaseLocation base) {
yaml.set(path + ".world-id", base.worldId().toString());
yaml.set(path + ".world-name", base.worldName());
yaml.set(path + ".x", base.x());
yaml.set(path + ".y", base.y());
yaml.set(path + ".z", base.z());
yaml.set(path + ".yaw", base.yaw());
yaml.set(path + ".pitch", base.pitch());
}
}
+40
View File
@@ -0,0 +1,40 @@
# Core Base progression
base-unlock-blocks: 250
navigation-unlock-blocks: 500
initial-radius: 10
initial-vertical-range: 25
relocation-cooldown-seconds: 86400
# Base size
stone-expansion-blocks: 500
deepslate-expansion-blocks: 1000
obsidian-expansion-blocks: 1000
first-expanded-radius: 25
second-expanded-radius: 75
third-expanded-radius: 150
# Flight
flight-warning-buffer: 5
second-flight-vertical-range: 100
# Base III teleportation and warm-up progression
teleport-unlock-placements: 200
second-warmup-placements: 1000
third-warmup-placements: 2000
instant-warmup-placements: 12000
initial-warmup-seconds: 30
second-warmup-seconds: 15
third-warmup-seconds: 5
# Teleport cooldown progression
first-cooldown-breaks: 1000
second-cooldown-breaks: 2000
third-cooldown-breaks: 3000
instant-cooldown-breaks: 5000
initial-teleport-cooldown-seconds: 10800
second-teleport-cooldown-seconds: 7200
third-teleport-cooldown-seconds: 3600
fourth-teleport-cooldown-seconds: 1800
# Base IV
visitor-unlock-diamond-cost: 128
+36
View File
@@ -0,0 +1,36 @@
name: SpigotBase
version: ${version}
main: games.dmg.spigotbase.SpigotBasePlugin
api-version: "1.20"
description: Progression-gated player bases and quality-of-life unlocks.
author: dmg.games
commands:
setbase:
description: Set your unlocked personal base.
usage: /setbase
base:
description: Teleport to or upgrade your base.
usage: /base [upgrade]
basenavigation:
description: Toggle particle navigation toward your base.
usage: /basenavigation
baseflight:
description: Toggle flight within your base.
usage: /baseflight
basevisitors:
description: Toggle visitor access to your base.
usage: /basevisitors
gotobase:
description: Visit an available player base.
usage: /gotobase <player>
baseprogress:
description: View progression or toggle progress boss bars.
usage: /baseprogress [bossbar]
baseadmin:
description: Administer Spigot Base.
usage: /baseadmin <subcommand>
permission: spigotbase.admin
permissions:
spigotbase.admin:
description: Allows administration of Spigot Base.
default: op