feat(pocket-base): add persistent pocket dimensions
Release / release (push) Successful in 2m37s
CI / build (push) Successful in 1m14s

This commit is contained in:
dmg
2026-08-21 23:16:04 -04:00
parent 8a9640d992
commit 11e3ad1720
32 changed files with 1597 additions and 13 deletions
@@ -15,8 +15,8 @@ import org.bukkit.inventory.PlayerInventory;
final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private static final List<String> SETTINGS = List.of(
"status", "upgrade", "visitors", "navigation", "flight", "border", "spawnable",
"bossbar"
"status", "upgrade", "pocket", "visitors", "navigation", "flight", "border",
"spawnable", "bossbar"
);
private static final List<String> VISITOR_MODES = List.of("allowed", "blocked");
private static final List<String> ENABLE_MODES = List.of("enable", "disable");
@@ -24,16 +24,27 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private final PluginSettingsProvider settings;
private final TeleportPolicy teleportPolicy;
private final BaseFlightController flightController;
private final PocketBaseManager pocketBases;
BaseSettingsCommand(
BaseStateManager stateManager,
PluginSettingsProvider settings,
BaseFlightController flightController
) {
this(stateManager, settings, flightController, null);
}
BaseSettingsCommand(
BaseStateManager stateManager,
PluginSettingsProvider settings,
BaseFlightController flightController,
PocketBaseManager pocketBases
) {
this.stateManager = stateManager;
this.settings = settings;
this.teleportPolicy = new TeleportPolicy(settings);
this.flightController = flightController;
this.pocketBases = pocketBases;
}
@Override
@@ -51,6 +62,10 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("upgrade")) {
return purchaseVisitorAccess(player, state);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("pocket")
&& arguments[1].equalsIgnoreCase("upgrade")) {
return purchasePocketUpgrade(player, state);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("visitors")) {
return updateVisitors(player, state, arguments[1]);
}
@@ -114,6 +129,53 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
return true;
}
private boolean purchasePocketUpgrade(Player player, PlayerState owner) {
if (pocketBases == null) {
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
return true;
}
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
player.sendMessage(ChatColor.RED
+ "Establish Base IV before purchasing a Pocket Base.");
return true;
}
PocketBaseState current = pocketBases.state(owner.playerId());
int price = current.level() == 0
? settings.current().pocketBaseUnlockCost()
: settings.current().pocketBaseUpgradeCost();
Material currency = Material.valueOf(settings.current().pocketBaseCurrencyMaterial());
PlayerInventory inventory = player.getInventory();
if (countCurrency(inventory, currency) < price) {
player.sendMessage(ChatColor.RED + "Pocket Base "
+ (current.level() == 0 ? "I" : "level " + (current.level() + 1))
+ " costs " + price + " "
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
return true;
}
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
removeCurrency(inventory, currency, price);
final PocketBaseState upgraded;
try {
upgraded = pocketBases.upgrade(owner.playerId());
} catch (IOException | RuntimeException exception) {
inventory.setStorageContents(snapshot);
player.sendMessage(ChatColor.RED
+ "The Pocket Base upgrade failed; your payment was restored.");
return true;
}
player.sendTitle(
ChatColor.GOLD + "Pocket Base " + upgraded.level() + " Unlocked",
ChatColor.YELLOW + "Build a portal inside your base",
settings.current().titleFadeInTicks(),
settings.current().titleStayTicks(),
settings.current().titleFadeOutTicks()
);
player.sendMessage(ChatColor.GREEN + "Pocket Base level " + upgraded.level()
+ " unlocked for " + price + " "
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
return true;
}
private boolean updateVisitors(Player player, PlayerState state, String mode) {
if (state.baseLevel() < 4) {
player.sendMessage(ChatColor.RED + "Base IV visitor access is still locked.");
@@ -274,6 +336,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
showFlightPath(player, state);
showWarmupPath(player, state);
showCooldownPath(player, state);
showPocketPath(player, state);
int spawnableThreshold = settings.current().spawnableOverlayUnlockPlacements();
player.sendMessage(ChatColor.YELLOW + "Spawnable Overlay: " + ChatColor.GRAY
+ state.totalBlocksPlaced() + "/" + spawnableThreshold + " placements; "
@@ -344,6 +407,17 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
+ DurationFormatter.friendly(teleportPolicy.warmup(state)) + "; " + detail);
}
private void showPocketPath(Player player, PlayerState owner) {
int level = pocketBases == null ? 0 : pocketBases.state(owner.playerId()).level();
int price = level == 0
? settings.current().pocketBaseUnlockCost()
: settings.current().pocketBaseUpgradeCost();
player.sendMessage(colorForPrerequisite(owner.baseLevel() >= 4 && owner.base().isPresent())
+ "Pocket Base " + level + ": " + ChatColor.GRAY + price + " "
+ settings.current().pocketBaseCurrencyMaterial().toLowerCase(Locale.ROOT)
+ " → /basesettings pocket upgrade");
}
private void showCooldownPath(Player player, PlayerState state) {
String detail = switch (state.cooldownLevel()) {
case 0 -> state.blocksBrokenInBase() + "/" + settings.current().firstCooldownBreaks();
@@ -373,6 +447,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
List<String> modes = arguments[0].equalsIgnoreCase("visitors")
? VISITOR_MODES
: switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "pocket" -> List.of("upgrade");
case "navigation", "flight", "border", "spawnable", "bossbar" -> ENABLE_MODES;
default -> List.of();
};
@@ -438,7 +513,8 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private static void sendUsage(Player player) {
player.sendMessage(ChatColor.RED + "Usage: /basesettings "
+ "[status|upgrade|visitors <allowed|blocked>|navigation <enable|disable>"
+ "[status|upgrade|pocket upgrade|visitors <allowed|blocked>"
+ "|navigation <enable|disable>"
+ "|flight <enable|disable>|border <enable|disable>"
+ "|spawnable <enable|disable>|bossbar <enable|disable>]");
}
@@ -0,0 +1,4 @@
package games.dmg.spigotbase;
public record BlockPosition(int x, int y, int z) {
}
@@ -37,11 +37,15 @@ public record PluginSettings(
long thirdTeleportCooldownSeconds,
long fourthTeleportCooldownSeconds,
int visitorUnlockDiamondCost,
int pocketBaseUnlockCost,
int pocketBaseUpgradeCost,
Set<String> baseUnlockMaterials,
String stoneExpansionMaterial,
String deepslateExpansionMaterial,
String obsidianExpansionMaterial,
String visitorCurrencyMaterial,
String pocketBaseCurrencyMaterial,
String pocketBasePortalFrameMaterial,
Set<String> cooldownExcludedMaterials,
int bossBarDurationTicks,
int navigationParticleCount,
@@ -79,6 +83,8 @@ public record PluginSettings(
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;
private static final int DEFAULT_POCKET_BASE_UNLOCK_COST = 64;
private static final int DEFAULT_POCKET_BASE_UPGRADE_COST = 16;
private static final int DEFAULT_BOSS_BAR_DURATION_TICKS = 60;
private static final int DEFAULT_NAVIGATION_PARTICLE_COUNT = 5;
private static final int DEFAULT_TITLE_FADE_IN_TICKS = 10;
@@ -140,6 +146,8 @@ public record PluginSettings(
throw new IllegalArgumentException("teleport cooldown durations must decrease");
}
requirePositive(visitorUnlockDiamondCost, "visitor-unlock-diamond-cost");
requirePositive(pocketBaseUnlockCost, "pocket-base-unlock-cost");
requirePositive(pocketBaseUpgradeCost, "pocket-base-upgrade-cost");
baseUnlockMaterials = normalizedSet(baseUnlockMaterials, "base-unlock-materials", false);
stoneExpansionMaterial = normalizedName(stoneExpansionMaterial, "stone-expansion-material");
deepslateExpansionMaterial = normalizedName(
@@ -151,6 +159,12 @@ public record PluginSettings(
visitorCurrencyMaterial = normalizedName(
visitorCurrencyMaterial, "visitor-currency-material"
);
pocketBaseCurrencyMaterial = normalizedName(
pocketBaseCurrencyMaterial, "pocket-base-currency-material"
);
pocketBasePortalFrameMaterial = normalizedName(
pocketBasePortalFrameMaterial, "pocket-base-portal-frame-material"
);
cooldownExcludedMaterials = normalizedSet(
cooldownExcludedMaterials, "cooldown-excluded-materials", true
);
@@ -214,11 +228,15 @@ public record PluginSettings(
DEFAULT_FOURTH_TELEPORT_COOLDOWN_SECONDS
),
integer(values, "visitor-unlock-diamond-cost", DEFAULT_VISITOR_UNLOCK_DIAMOND_COST),
integer(values, "pocket-base-unlock-cost", DEFAULT_POCKET_BASE_UNLOCK_COST),
integer(values, "pocket-base-upgrade-cost", DEFAULT_POCKET_BASE_UPGRADE_COST),
stringSet(values, "base-unlock-materials", Set.of("GRASS_BLOCK", "DIRT")),
string(values, "stone-expansion-material", "STONE"),
string(values, "deepslate-expansion-material", "DEEPSLATE"),
string(values, "obsidian-expansion-material", "OBSIDIAN"),
string(values, "visitor-currency-material", "DIAMOND"),
string(values, "pocket-base-currency-material", "DIAMOND_BLOCK"),
string(values, "pocket-base-portal-frame-material", "DIAMOND_BLOCK"),
stringSet(values, "cooldown-excluded-materials", Set.of()),
integer(values, "boss-bar-duration-ticks", DEFAULT_BOSS_BAR_DURATION_TICKS),
integer(values, "navigation-particle-count", DEFAULT_NAVIGATION_PARTICLE_COUNT),
@@ -13,7 +13,8 @@ final class PluginSettingsValidator {
Stream.of(
settings.stoneExpansionMaterial(),
settings.deepslateExpansionMaterial(),
settings.obsidianExpansionMaterial()
settings.obsidianExpansionMaterial(),
settings.pocketBasePortalFrameMaterial()
)
).forEach(name -> {
Material material = Material.matchMaterial(name);
@@ -26,10 +27,15 @@ final class PluginSettingsValidator {
throw new IllegalArgumentException(name + " is not a known material");
}
}
Material currency = Material.matchMaterial(settings.visitorCurrencyMaterial());
if (currency == null || currency.isAir()) {
throw new IllegalArgumentException("visitor-currency-material is invalid");
}
validateCurrency(settings.visitorCurrencyMaterial(), "visitor-currency-material");
validateCurrency(settings.pocketBaseCurrencyMaterial(), "pocket-base-currency-material");
return settings;
}
private static void validateCurrency(String name, String setting) {
Material currency = Material.matchMaterial(name);
if (currency == null || currency.isAir()) {
throw new IllegalArgumentException(setting + " is invalid");
}
}
}
@@ -0,0 +1,312 @@
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.logging.Level;
import java.util.logging.Logger;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.block.Block;
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.block.Action;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
final class PocketBaseController implements Listener, Runnable {
private static final int VOID_RETURN_Y = -64;
private static final long PORTAL_COOLDOWN_NANOS = 2_000_000_000L;
private final Server server;
private final BaseStateManager baseStates;
private final BaseBoundsService baseBounds;
private final PocketBaseManager pocketBases;
private final PluginSettingsProvider settings;
private final Logger logger;
private final Map<UUID, Long> cooldownUntil = new HashMap<>();
PocketBaseController(
Server server,
BaseStateManager baseStates,
BaseBoundsService baseBounds,
PocketBaseManager pocketBases,
PluginSettingsProvider settings,
Logger logger
) {
this.server = server;
this.baseStates = baseStates;
this.baseBounds = baseBounds;
this.pocketBases = pocketBases;
this.settings = settings;
this.logger = logger;
}
@Override
public void run() {
validateEntrances();
for (PocketBaseState state : pocketBases.knownStates().values()) {
state.entrance().ifPresent(this::showPortal);
if (state.level() > 0 && pocketBases.returnPortalIsIntact(state.ownerId())) {
showPortal(pocketBases.returnPortal(state.ownerId()));
}
}
long now = System.nanoTime();
cooldownUntil.entrySet().removeIf(entry -> entry.getValue() <= now);
}
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onActivate(PlayerInteractEvent event) {
if (event.getAction() != Action.RIGHT_CLICK_BLOCK || event.getClickedBlock() == null
|| event.getItem() == null
|| event.getItem().getType() != Material.FLINT_AND_STEEL) {
return;
}
Player player = event.getPlayer();
PocketBaseState pocket = pocketBases.state(player.getUniqueId());
if (pocket.level() < 1) {
return;
}
PlayerState owner = baseStates.player(player.getUniqueId(), player.getName());
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
return;
}
Block clicked = event.getClickedBlock();
World world = clicked.getWorld();
Material frameMaterial = Material.valueOf(
settings.current().pocketBasePortalFrameMaterial()
);
Optional<PocketPortalLocation> portal = PocketPortalGeometry.findFrame(
position(clicked),
world.getUID(),
world.getName(),
candidate -> block(world, candidate).getType() == frameMaterial,
candidate -> isAir(block(world, candidate).getType())
);
if (portal.isEmpty()) {
return;
}
BaseArea area = baseBounds.area(owner);
if (PocketPortalGeometry.frameBlocks(portal.orElseThrow()).stream().anyMatch(candidate ->
!area.contains(world.getUID(), candidate.x(), candidate.y(), candidate.z()))) {
player.sendMessage(ChatColor.RED
+ "The complete Pocket Base portal must be inside your base.");
event.setCancelled(true);
return;
}
try {
pocketBases.activateEntrance(player.getUniqueId(), portal.orElseThrow());
} catch (IOException | RuntimeException exception) {
logger.log(Level.SEVERE, "Could not activate Pocket Base entrance", exception);
player.sendMessage(ChatColor.RED + "The Pocket Base portal could not be activated.");
event.setCancelled(true);
return;
}
event.setCancelled(true);
player.sendMessage(ChatColor.GREEN + "Pocket Base portal activated.");
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBreak(BlockBreakEvent event) {
BlockPosition broken = position(event.getBlock());
UUID worldId = event.getBlock().getWorld().getUID();
for (PocketBaseState state : pocketBases.knownStates().values()) {
if (state.entrance().isPresent()) {
PocketPortalLocation portal = state.entrance().orElseThrow();
if (portal.worldId().equals(worldId)
&& PocketPortalGeometry.isFrame(portal, broken)) {
deactivate(state.ownerId());
}
}
}
}
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onPlace(BlockPlaceEvent event) {
Block block = event.getBlockPlaced();
Optional<UUID> ownerId = pocketBases.ownerForPocketWorld(block.getWorld().getUID());
if (ownerId.isEmpty()) {
return;
}
PocketBaseState state = pocketBases.state(ownerId.orElseThrow());
if (!pocketBases.policy().contains(state.level(), block.getX(), block.getZ())) {
event.setCancelled(true);
event.getPlayer().sendMessage(ChatColor.RED
+ "That block is outside the unlocked Pocket Base boundary.");
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onMove(PlayerMoveEvent event) {
Location destination = event.getTo();
if (destination == null || onCooldown(event.getPlayer())) {
return;
}
Player player = event.getPlayer();
World world = destination.getWorld();
if (world == null) {
return;
}
Optional<UUID> pocketOwner = pocketBases.ownerForPocketWorld(world.getUID());
if (pocketOwner.isPresent()) {
UUID ownerId = pocketOwner.orElseThrow();
if (destination.getY() < VOID_RETURN_Y
|| pocketBases.returnPortalIsIntact(ownerId)
&& PocketPortalGeometry.isInterior(
pocketBases.returnPortal(ownerId), position(destination)
)) {
leavePocket(player, ownerId);
}
return;
}
BlockPosition position = position(destination);
for (PocketBaseState state : pocketBases.knownStates().values()) {
if (state.entrance().isEmpty()) {
continue;
}
PocketPortalLocation portal = state.entrance().orElseThrow();
if (portal.worldId().equals(world.getUID())
&& isIntact(portal)
&& PocketPortalGeometry.isInterior(portal, position)) {
teleport(player, pocketBases.pocketArrival(state.ownerId()));
return;
}
}
}
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onVoidDamage(EntityDamageEvent event) {
Entity entity = event.getEntity();
if (event.getCause() != EntityDamageEvent.DamageCause.VOID
|| !(entity instanceof Player player)) {
return;
}
Optional<UUID> owner = pocketBases.ownerForPocketWorld(player.getWorld().getUID());
if (owner.isPresent()) {
event.setCancelled(true);
leavePocket(player, owner.orElseThrow());
}
}
boolean isPocketWorld(UUID worldId) {
return pocketBases.ownerForPocketWorld(worldId).isPresent();
}
void deactivateForRelocation(UUID ownerId) {
deactivate(ownerId);
}
private void leavePocket(Player player, UUID ownerId) {
PocketBaseState state = pocketBases.state(ownerId);
if (state.entrance().isPresent() && isIntact(state.entrance().orElseThrow())) {
teleport(player, outsidePortal(state.entrance().orElseThrow()));
return;
}
World fallbackWorld = server.getWorlds().get(0);
teleport(player, fallbackWorld.getSpawnLocation());
}
private void teleport(Player player, Location destination) {
cooldownUntil.put(player.getUniqueId(), System.nanoTime() + PORTAL_COOLDOWN_NANOS);
player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN);
}
private boolean onCooldown(Player player) {
return cooldownUntil.getOrDefault(player.getUniqueId(), 0L) > System.nanoTime();
}
private void validateEntrances() {
for (PocketBaseState state : pocketBases.knownStates().values()) {
if (state.entrance().isEmpty()) {
continue;
}
PlayerState owner = baseStates.knownPlayers().get(state.ownerId());
PocketPortalLocation portal = state.entrance().orElseThrow();
if (owner == null || owner.baseLevel() < 4 || owner.base().isEmpty()
|| !isIntact(portal)
|| PocketPortalGeometry.frameBlocks(portal).stream().anyMatch(position ->
!baseBounds.area(owner).contains(
portal.worldId(), position.x(), position.y(), position.z()
))) {
deactivate(state.ownerId());
}
}
}
private boolean isIntact(PocketPortalLocation portal) {
World world = server.getWorld(portal.worldId());
if (world == null) {
return false;
}
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
return PocketPortalGeometry.frameBlocks(portal).stream()
.allMatch(position -> block(world, position).getType() == frame);
}
private void showPortal(PocketPortalLocation portal) {
World world = server.getWorld(portal.worldId());
if (world == null || !isIntact(portal)) {
return;
}
for (BlockPosition position : PocketPortalGeometry.interiorBlocks(portal)) {
world.spawnParticle(
Particle.PORTAL,
position.x() + 0.5,
position.y() + 0.5,
position.z() + 0.5,
4,
0.25,
0.35,
0.25,
0.02
);
}
}
private void deactivate(UUID ownerId) {
try {
pocketBases.deactivateEntrance(ownerId);
} catch (IOException exception) {
logger.log(Level.SEVERE, "Could not deactivate Pocket Base entrance", exception);
}
}
private Location outsidePortal(PocketPortalLocation portal) {
World world = server.getWorld(portal.worldId());
if (world == null) {
return server.getWorlds().get(0).getSpawnLocation();
}
return portal.axis() == PocketPortalAxis.X
? new Location(world, portal.x() + 2.0, portal.y() + 1.0, portal.z() + 1.5)
: new Location(world, portal.x() + 1.5, portal.y() + 1.0, portal.z() + 2.0);
}
private static boolean isAir(Material material) {
return material == Material.AIR || material == Material.CAVE_AIR
|| material == Material.VOID_AIR;
}
private static Block block(World world, BlockPosition position) {
return world.getBlockAt(position.x(), position.y(), position.z());
}
private static BlockPosition position(Block block) {
return new BlockPosition(block.getX(), block.getY(), block.getZ());
}
private static BlockPosition position(Location location) {
return new BlockPosition(location.getBlockX(), location.getBlockY(), location.getBlockZ());
}
}
@@ -0,0 +1,86 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.World;
final class PocketBaseManager {
private final PocketBaseStateManager states;
private final PocketBaseWorldService worlds;
private final PocketBasePolicy policy;
PocketBaseManager(
PocketBaseStateManager states,
PocketBaseWorldService worlds,
PluginSettingsProvider settings
) {
this.states = states;
this.worlds = worlds;
this.policy = new PocketBasePolicy(settings);
}
void loadWorlds() {
worlds.loadExisting(states.knownStates());
}
PocketBaseState state(UUID ownerId) {
return states.state(ownerId);
}
Map<UUID, PocketBaseState> knownStates() {
return states.knownStates();
}
PocketBaseState upgrade(UUID ownerId) throws IOException {
PocketBaseState current = states.state(ownerId);
int nextLevel = Math.addExact(current.level(), 1);
policy.size(nextLevel);
worlds.expand(ownerId, current.level(), nextLevel);
return states.updateAndSave(ownerId, state -> state.withLevel(nextLevel));
}
PocketBaseState activateEntrance(
UUID ownerId,
PocketPortalLocation portal
) throws IOException {
PocketBaseState current = states.state(ownerId);
if (current.level() < 1) {
throw new IllegalStateException("Pocket Base I is still locked");
}
return states.updateAndSave(ownerId, state -> state.withEntrance(portal));
}
void deactivateEntrance(UUID ownerId) throws IOException {
PocketBaseState current = states.state(ownerId);
if (current.entrance().isPresent()) {
states.updateAndSave(ownerId, PocketBaseState::withoutEntrance);
}
}
Optional<UUID> ownerForPocketWorld(UUID worldId) {
return worlds.ownerForWorld(worldId);
}
Location pocketArrival(UUID ownerId) {
return worlds.arrival(ownerId);
}
PocketPortalLocation returnPortal(UUID ownerId) {
return worlds.returnPortal(ownerId);
}
boolean returnPortalIsIntact(UUID ownerId) {
return worlds.returnPortalIsIntact(ownerId);
}
World pocketWorld(UUID ownerId) {
return worlds.world(ownerId);
}
PocketBasePolicy policy() {
return policy;
}
}
@@ -0,0 +1,31 @@
package games.dmg.spigotbase;
public final class PocketBasePolicy {
private final PluginSettingsProvider settings;
public PocketBasePolicy(PluginSettings settings) {
this(new PluginSettingsProvider(settings));
}
public PocketBasePolicy(PluginSettingsProvider settings) {
this.settings = settings;
}
public int size(int level) {
if (level < 1) {
throw new IllegalArgumentException("Pocket Base level must be positive");
}
return Math.addExact(64, Math.multiplyExact(32, level - 1));
}
public int price(PocketBaseState state) {
return state.level() == 0
? settings.current().pocketBaseUnlockCost()
: settings.current().pocketBaseUpgradeCost();
}
public boolean contains(int level, int x, int z) {
int halfSize = size(level) / 2;
return x >= -halfSize && x < halfSize && z >= -halfSize && z < halfSize;
}
}
@@ -0,0 +1,39 @@
package games.dmg.spigotbase;
import java.util.Optional;
import java.util.UUID;
public record PocketBaseState(
UUID ownerId,
int level,
Optional<PocketPortalLocation> entrance
) {
public PocketBaseState {
if (ownerId == null) {
throw new IllegalArgumentException("owner ID is required");
}
if (level < 0) {
throw new IllegalArgumentException("Pocket Base level must not be negative");
}
entrance = entrance == null ? Optional.empty() : entrance;
if (level == 0 && entrance.isPresent()) {
throw new IllegalArgumentException("a locked Pocket Base cannot have an entrance");
}
}
public static PocketBaseState locked(UUID ownerId) {
return new PocketBaseState(ownerId, 0, Optional.empty());
}
public PocketBaseState withLevel(int newLevel) {
return new PocketBaseState(ownerId, newLevel, entrance);
}
public PocketBaseState withEntrance(PocketPortalLocation portal) {
return new PocketBaseState(ownerId, level, Optional.of(portal));
}
public PocketBaseState withoutEntrance() {
return new PocketBaseState(ownerId, level, Optional.empty());
}
}
@@ -0,0 +1,41 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.UnaryOperator;
final class PocketBaseStateManager {
private final YamlPocketBaseRepository repository;
private final Map<UUID, PocketBaseState> states;
PocketBaseStateManager(YamlPocketBaseRepository repository) throws IOException {
this.repository = repository;
this.states = new HashMap<>(repository.load());
}
PocketBaseState state(UUID ownerId) {
return states.getOrDefault(ownerId, PocketBaseState.locked(ownerId));
}
Map<UUID, PocketBaseState> knownStates() {
return Map.copyOf(states);
}
PocketBaseState updateAndSave(
UUID ownerId,
UnaryOperator<PocketBaseState> operation
) throws IOException {
PocketBaseState updated = operation.apply(state(ownerId));
if (!updated.ownerId().equals(ownerId)) {
throw new IllegalArgumentException("Pocket Base owner ID cannot change");
}
Map<UUID, PocketBaseState> proposed = new HashMap<>(states);
proposed.put(ownerId, updated);
repository.save(proposed);
states.clear();
states.putAll(proposed);
return updated;
}
}
@@ -0,0 +1,120 @@
package games.dmg.spigotbase;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.WorldCreator;
final class PocketBaseWorldService {
private static final int PLATFORM_Y = 64;
private static final String WORLD_PREFIX = "spigot_base_pocket_";
private final Server server;
private final PluginSettingsProvider settings;
private final PocketBasePolicy policy;
private final Map<UUID, UUID> ownersByWorld = new HashMap<>();
PocketBaseWorldService(Server server, PluginSettingsProvider settings) {
this.server = server;
this.settings = settings;
this.policy = new PocketBasePolicy(settings);
}
void loadExisting(Map<UUID, PocketBaseState> states) {
for (PocketBaseState state : states.values()) {
if (state.level() > 0) {
ensureWorld(state.ownerId());
}
}
}
World expand(UUID ownerId, int previousLevel, int newLevel) {
World world = ensureWorld(ownerId);
int newHalfSize = policy.size(newLevel) / 2;
int previousHalfSize = previousLevel == 0 ? 0 : policy.size(previousLevel) / 2;
for (int x = -newHalfSize; x < newHalfSize; x++) {
for (int z = -newHalfSize; z < newHalfSize; z++) {
boolean previouslyUnlocked = previousLevel > 0
&& x >= -previousHalfSize && x < previousHalfSize
&& z >= -previousHalfSize && z < previousHalfSize;
if (previouslyUnlocked) {
continue;
}
for (int y = PLATFORM_Y - 3; y < PLATFORM_Y; y++) {
world.getBlockAt(x, y, z).setType(Material.DIRT, false);
}
world.getBlockAt(x, PLATFORM_Y, z).setType(Material.GRASS_BLOCK, false);
}
}
if (previousLevel == 0) {
createReturnPortal(world);
world.setSpawnLocation(0, PLATFORM_Y + 1, 5);
}
world.save();
return world;
}
Optional<UUID> ownerForWorld(UUID worldId) {
return Optional.ofNullable(ownersByWorld.get(worldId));
}
World world(UUID ownerId) {
return ensureWorld(ownerId);
}
Location arrival(UUID ownerId) {
return new Location(ensureWorld(ownerId), 0.5, PLATFORM_Y + 1, 5.5, 180.0f, 0.0f);
}
PocketPortalLocation returnPortal(UUID ownerId) {
World world = ensureWorld(ownerId);
return new PocketPortalLocation(
world.getUID(), world.getName(), -2, PLATFORM_Y + 1, 0, PocketPortalAxis.X
);
}
boolean returnPortalIsIntact(UUID ownerId) {
PocketPortalLocation portal = returnPortal(ownerId);
World world = ensureWorld(ownerId);
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
return PocketPortalGeometry.frameBlocks(portal).stream()
.allMatch(position -> world.getBlockAt(
position.x(), position.y(), position.z()
).getType() == frame);
}
private World ensureWorld(UUID ownerId) {
String name = worldName(ownerId);
World world = server.getWorld(name);
if (world == null) {
WorldCreator creator = new WorldCreator(name)
.environment(World.Environment.NORMAL)
.generator(new VoidPocketChunkGenerator())
.generateStructures(false);
world = server.createWorld(creator);
}
if (world == null) {
throw new IllegalStateException("Could not create Pocket Base world " + name);
}
ownersByWorld.put(world.getUID(), ownerId);
return world;
}
private void createReturnPortal(World world) {
PocketPortalLocation portal = new PocketPortalLocation(
world.getUID(), world.getName(), -2, PLATFORM_Y + 1, 0, PocketPortalAxis.X
);
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
for (BlockPosition position : PocketPortalGeometry.frameBlocks(portal)) {
world.getBlockAt(position.x(), position.y(), position.z()).setType(frame, false);
}
}
private static String worldName(UUID ownerId) {
return WORLD_PREFIX + ownerId.toString().replace("-", "");
}
}
@@ -0,0 +1,6 @@
package games.dmg.spigotbase;
public enum PocketPortalAxis {
X,
Z
}
@@ -0,0 +1,83 @@
package games.dmg.spigotbase;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Predicate;
public final class PocketPortalGeometry {
private PocketPortalGeometry() {
}
public static Optional<PocketPortalLocation> findFrame(
BlockPosition clicked,
UUID worldId,
String worldName,
Predicate<BlockPosition> isFrameMaterial,
Predicate<BlockPosition> isOpenInterior
) {
for (PocketPortalAxis axis : PocketPortalAxis.values()) {
int clickedHorizontal = axis == PocketPortalAxis.X ? clicked.x() : clicked.z();
for (int horizontalOffset = 0; horizontalOffset < 4; horizontalOffset++) {
for (int verticalOffset = 0; verticalOffset < 5; verticalOffset++) {
int originHorizontal = clickedHorizontal - horizontalOffset;
int originY = clicked.y() - verticalOffset;
PocketPortalLocation candidate = axis == PocketPortalAxis.X
? new PocketPortalLocation(
worldId, worldName, originHorizontal, originY, clicked.z(), axis
)
: new PocketPortalLocation(
worldId, worldName, clicked.x(), originY, originHorizontal, axis
);
if (frameBlocks(candidate).stream().allMatch(isFrameMaterial)
&& interiorBlocks(candidate).stream().allMatch(isOpenInterior)) {
return Optional.of(candidate);
}
}
}
}
return Optional.empty();
}
public static List<BlockPosition> frameBlocks(PocketPortalLocation portal) {
List<BlockPosition> blocks = new ArrayList<>(14);
for (int horizontal = 0; horizontal < 4; horizontal++) {
blocks.add(position(portal, horizontal, 0));
blocks.add(position(portal, horizontal, 4));
}
for (int vertical = 1; vertical < 4; vertical++) {
blocks.add(position(portal, 0, vertical));
blocks.add(position(portal, 3, vertical));
}
return List.copyOf(blocks);
}
public static List<BlockPosition> interiorBlocks(PocketPortalLocation portal) {
List<BlockPosition> blocks = new ArrayList<>(6);
for (int horizontal = 1; horizontal < 3; horizontal++) {
for (int vertical = 1; vertical < 4; vertical++) {
blocks.add(position(portal, horizontal, vertical));
}
}
return List.copyOf(blocks);
}
public static boolean isFrame(PocketPortalLocation portal, BlockPosition position) {
return frameBlocks(portal).contains(position);
}
public static boolean isInterior(PocketPortalLocation portal, BlockPosition position) {
return interiorBlocks(portal).contains(position);
}
private static BlockPosition position(
PocketPortalLocation portal,
int horizontal,
int vertical
) {
return portal.axis() == PocketPortalAxis.X
? new BlockPosition(portal.x() + horizontal, portal.y() + vertical, portal.z())
: new BlockPosition(portal.x(), portal.y() + vertical, portal.z() + horizontal);
}
}
@@ -0,0 +1,24 @@
package games.dmg.spigotbase;
import java.util.UUID;
public record PocketPortalLocation(
UUID worldId,
String worldName,
int x,
int y,
int z,
PocketPortalAxis axis
) {
public PocketPortalLocation {
if (worldId == null) {
throw new IllegalArgumentException("portal world ID is required");
}
if (worldName == null || worldName.isBlank()) {
throw new IllegalArgumentException("portal world name is required");
}
if (axis == null) {
throw new IllegalArgumentException("portal axis is required");
}
}
}
@@ -16,11 +16,22 @@ final class SetBaseCommand implements CommandExecutor {
private final BaseStateManager stateManager;
private final BaseService baseService;
private final Clock clock;
private final PocketBaseController pocketBaseController;
SetBaseCommand(BaseStateManager stateManager, BaseService baseService, Clock clock) {
this(stateManager, baseService, clock, null);
}
SetBaseCommand(
BaseStateManager stateManager,
BaseService baseService,
Clock clock,
PocketBaseController pocketBaseController
) {
this.stateManager = stateManager;
this.baseService = baseService;
this.clock = clock;
this.pocketBaseController = pocketBaseController;
}
@Override
@@ -47,6 +58,10 @@ final class SetBaseCommand implements CommandExecutor {
player.sendMessage(ChatColor.RED + "Your current world is unavailable.");
return true;
}
if (pocketBaseController != null && pocketBaseController.isPocketWorld(world.getUID())) {
player.sendMessage(ChatColor.RED + "You cannot set your normal base inside a Pocket Base.");
return true;
}
BaseLocation base = new BaseLocation(
world.getUID(), world.getName(), location.getBlockX(), location.getBlockY(),
location.getBlockZ(), location.getYaw(), location.getPitch()
@@ -55,6 +70,9 @@ final class SetBaseCommand implements CommandExecutor {
player.getUniqueId(), player.getName(), current -> baseService.setBase(current, base, now)
);
stateManager.saveIfDirty();
if (pocketBaseController != null) {
pocketBaseController.deactivateForRelocation(player.getUniqueId());
}
player.sendMessage(ChatColor.GREEN + "Base set at " + base.x() + ", " + base.y() + ", " + base.z() + ".");
return true;
}
@@ -14,6 +14,8 @@ public final class SpigotBasePlugin extends JavaPlugin {
private BaseProgressListener progressListener;
private BaseFlightController flightController;
private BaseTeleportManager teleportManager;
private PocketBaseManager pocketBaseManager;
private PocketBaseController pocketBaseController;
@Override
public void onEnable() {
@@ -27,7 +29,18 @@ public final class SpigotBasePlugin extends JavaPlugin {
new YamlBaseStateRepository(getDataFolder().toPath().resolve("state.yml")),
getLogger()
);
} catch (IllegalArgumentException | IOException exception) {
PocketBaseStateManager pocketStates = new PocketBaseStateManager(
new YamlPocketBaseRepository(
getDataFolder().toPath().resolve("pocket-bases.yml")
)
);
pocketBaseManager = new PocketBaseManager(
pocketStates,
new PocketBaseWorldService(getServer(), settingsProvider),
settingsProvider
);
pocketBaseManager.loadWorlds();
} catch (RuntimeException | IOException exception) {
getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception);
getServer().getPluginManager().disablePlugin(this);
return;
@@ -62,15 +75,27 @@ public final class SpigotBasePlugin extends JavaPlugin {
new SafeBaseDestination(),
Clock.systemUTC()
);
pocketBaseController = new PocketBaseController(
getServer(),
stateManager,
boundsService,
pocketBaseManager,
settingsProvider,
getLogger()
);
getServer().getPluginManager().registerEvents(progressListener, this);
getServer().getPluginManager().registerEvents(teleportManager, this);
getServer().getPluginManager().registerEvents(pocketBaseController, this);
command("setbase").setExecutor(new SetBaseCommand(stateManager, baseService, Clock.systemUTC()));
command("setbase").setExecutor(new SetBaseCommand(
stateManager, baseService, Clock.systemUTC(), pocketBaseController
));
command("base").setExecutor(new BaseCommand(teleportManager));
BaseSettingsCommand settingsCommand = new BaseSettingsCommand(
stateManager,
settingsProvider,
flightController
flightController,
pocketBaseManager
);
command("basesettings").setExecutor(settingsCommand);
command("basesettings").setTabCompleter(settingsCommand);
@@ -107,6 +132,7 @@ public final class SpigotBasePlugin extends JavaPlugin {
10L
);
getServer().getScheduler().runTaskTimer(this, flightController, 5L, 5L);
getServer().getScheduler().runTaskTimer(this, pocketBaseController, 5L, 5L);
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
getLogger().info("Spigot Base enabled.");
}
@@ -0,0 +1,35 @@
package games.dmg.spigotbase;
import org.bukkit.generator.ChunkGenerator;
final class VoidPocketChunkGenerator extends ChunkGenerator {
@Override
public boolean shouldGenerateNoise() {
return false;
}
@Override
public boolean shouldGenerateSurface() {
return false;
}
@Override
public boolean shouldGenerateCaves() {
return false;
}
@Override
public boolean shouldGenerateDecorations() {
return false;
}
@Override
public boolean shouldGenerateMobs() {
return false;
}
@Override
public boolean shouldGenerateStructures() {
return false;
}
}
@@ -0,0 +1,121 @@
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.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 YamlPocketBaseRepository {
private final Path stateFile;
public YamlPocketBaseRepository(Path stateFile) {
this.stateFile = stateFile;
}
public Map<UUID, PocketBaseState> load() throws IOException {
if (!Files.exists(stateFile)) {
return Map.of();
}
YamlConfiguration yaml = new YamlConfiguration();
try {
yaml.load(stateFile.toFile());
} catch (InvalidConfigurationException exception) {
throw new IOException("Pocket Base state is not valid YAML", exception);
}
Map<UUID, PocketBaseState> states = new HashMap<>();
ConfigurationSection owners = yaml.getConfigurationSection("owners");
if (owners == null) {
return states;
}
for (String key : owners.getKeys(false)) {
try {
UUID ownerId = UUID.fromString(key);
String path = "owners." + key;
int level = yaml.getInt(path + ".level", 0);
PocketBaseState state = new PocketBaseState(
ownerId,
level,
loadPortal(yaml, path + ".entrance")
);
states.put(ownerId, state);
} catch (IllegalArgumentException ignored) {
// Invalid records grant no Pocket Base progression.
}
}
return states;
}
public void save(Map<UUID, PocketBaseState> states) throws IOException {
Path parent = stateFile.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
YamlConfiguration yaml = new YamlConfiguration();
for (PocketBaseState state : states.values()) {
String path = "owners." + state.ownerId();
yaml.set(path + ".level", state.level());
state.entrance().ifPresent(portal -> savePortal(yaml, path + ".entrance", portal));
}
Path temporary = Files.createTempFile(parent, "spigot-base-pocket-", ".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 Optional<PocketPortalLocation> loadPortal(
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");
String axis = yaml.getString(path + ".axis");
if (worldId == null || worldName == null || axis == null
|| !yaml.isInt(path + ".x") || !yaml.isInt(path + ".y")
|| !yaml.isInt(path + ".z")) {
throw new IllegalArgumentException("invalid Pocket Base entrance");
}
return Optional.of(new PocketPortalLocation(
UUID.fromString(worldId),
worldName,
yaml.getInt(path + ".x"),
yaml.getInt(path + ".y"),
yaml.getInt(path + ".z"),
PocketPortalAxis.valueOf(axis)
));
}
private static void savePortal(
YamlConfiguration yaml,
String path,
PocketPortalLocation portal
) {
yaml.set(path + ".world-id", portal.worldId().toString());
yaml.set(path + ".world-name", portal.worldName());
yaml.set(path + ".x", portal.x());
yaml.set(path + ".y", portal.y());
yaml.set(path + ".z", portal.z());
yaml.set(path + ".axis", portal.axis().name());
}
}