feat(base): implement progression system
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user