2 Commits
Author SHA1 Message Date
dmg 11e3ad1720 feat(pocket-base): add persistent pocket dimensions
Release / release (push) Successful in 2m37s
CI / build (push) Successful in 1m14s
2026-08-21 23:16:04 -04:00
dmg 8a9640d992 fix(teleport): require same dimension for visits
Release / release (push) Successful in 2m24s
CI / build (push) Successful in 1m2s
2026-08-21 22:36:10 -04:00
34 changed files with 1670 additions and 14 deletions
+3
View File
@@ -28,6 +28,7 @@ The plugin JAR is written to `build/libs/`.
/basesettings (alias: /homesettings)
/basesettings status
/basesettings upgrade
/basesettings pocket upgrade
/basesettings visitors <allowed|blocked>
/basesettings navigation <enable|disable>
/basesettings flight <enable|disable>
@@ -42,6 +43,8 @@ After 250 Survival-mode block placements anywhere by default, the spawnable over
`/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown.
Base IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world.
## Administration
The `spigotbase.admin` permission is granted to server operators by default. `/homeadmin` aliases `/baseadmin`.
+3
View File
@@ -20,3 +20,6 @@ description: Catalog of user stories for the Spigot Base plugin.
12. [US-012: Build and release the plugin](us-012-build-and-release-plugin.md)
13. [US-013: Visualize the base border](us-013-visualize-base-border.md)
14. [US-014: Highlight spawnable blocks](us-014-highlight-spawnable-blocks.md)
15. [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
16. [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
17. [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
@@ -21,6 +21,7 @@ As a **player with Base III**, I want to open my base to visitors so that other
- [x] `/gotobase <owner>` autocompletes bases that the requesting player is currently eligible to visit.
- [x] `/visit <owner>` aliases `/gotobase <owner>` with identical autocomplete, including eligible bases whose owners are offline.
- [x] Enabled bases remain visitable while their owners are offline.
- [x] `/visit` and `/gotobase` require the visitor to be in the destination base's recorded world; cross-dimension requests are rejected before warm-up with a clear message and without consuming cooldown.
- [x] A visitor teleport uses the destination owner's current warm-up tier, but always requires at least a one-second stationary warm-up; `/visit` and `/gotobase` never teleport instantly.
- [x] Looking around is permitted, while movement between block coordinates, damage, teleportation, world change, death, logout, or a conflicting teleport cancels the visitor warm-up.
- [x] Cancellation or destination failure does not consume a visitor cooldown.
@@ -36,3 +37,5 @@ As a **player with Base III**, I want to open my base to visitors so that other
- [US-005: Unlock Base III teleportation](us-005-unlock-base-teleportation.md)
- [US-006: Reduce the base teleport warm-up](us-006-reduce-teleport-warmup.md)
- [US-007: Reduce the base teleport cooldown](us-007-reduce-teleport-cooldown.md)
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
- [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
@@ -0,0 +1,30 @@
---
type: User Story
title: "US-015: Unlock a Pocket Base"
description: Let Base IV owners purchase a persistent private void world that serves as the foundation for portal access and expansion.
status: done
---
# US-015: Unlock a Pocket Base
As a **player with an established Base IV**, I want to unlock a persistent Pocket Base so that I can build in a personal space beyond the normal world.
## Acceptance criteria
- [x] Pocket Base progression requires an established Base IV.
- [x] `/basesettings pocket upgrade` unlocks Pocket Base I and is offered through contextual autocomplete.
- [x] Unlocking atomically charges a configurable currency and amount that default to 64 diamond blocks.
- [x] Insufficient funds or failed persistence or world creation does not consume currency or grant Pocket Base I.
- [x] Pocket Base I creates a persistent owner-specific void world keyed by the owner's UUID.
- [x] The initial platform is 64 by 64 blocks with one grass layer over three dirt layers.
- [x] The pocket world provides a safe arrival location and a generated return portal.
- [x] Pocket Base worlds, contents, ownership, and progression persist through reconnects, restarts, and normal-base relocation.
- [x] `/basesettings status` displays the current Pocket Base level and the next upgrade requirement.
## Related
- [US-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md)
- [US-009: View progression and notifications](us-009-view-progression-and-notifications.md)
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
- [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
- [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
@@ -0,0 +1,35 @@
---
type: User Story
title: "US-016: Build and use Pocket Base portals"
description: Let an owner create one public portal between their normal base and persistent Pocket Base.
status: done
---
# US-016: Build and use Pocket Base portals
As a **Pocket Base owner**, I want to connect my normal base to my Pocket Base with a public portal so that I and other players can move between them naturally.
## Acceptance criteria
- [x] The entrance uses a standard 4-by-5 outer frame with a 2-by-3 interior and a configurable frame material that defaults to diamond blocks.
- [x] Only the Pocket Base owner can activate a complete entrance frame with flint and steel.
- [x] Every required frame block must be within the owner's current normal-base bounds when the entrance is activated.
- [x] Custom particles and collision provide portal-like visuals and travel without relying on vanilla Nether portal blocks.
- [x] Each owner has at most one active entrance portal.
- [x] Activating another valid entrance deactivates the previous entrance without removing its frame blocks.
- [x] Breaking any required frame block immediately deactivates the entrance.
- [x] Any player can use an active entrance without a separate visitor setting, warm-up, or cooldown.
- [x] Visitors can build, break blocks, open containers, and otherwise interact normally inside the Pocket Base.
- [x] The generated return portal sends players safely to the owner's active entrance.
- [x] Return travel falls back to the server spawn when the owner has no valid active entrance.
- [x] Falling below Y -64 performs the same safe return without void damage.
- [x] Players who disconnect inside a Pocket Base remain there when they reconnect.
- [x] Moving the normal base deactivates its entrance while preserving the Pocket Base world and its contents.
- [x] Portal ownership and the active entrance location persist through restarts.
## Related
- [US-003: Expand the base](us-003-expand-the-base.md)
- [US-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md)
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
- [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
@@ -0,0 +1,28 @@
---
type: User Story
title: "US-017: Expand a Pocket Base"
description: Let Pocket Base owners purchase unlimited levels that add permanent buildable terrain around their pocket world.
status: done
---
# US-017: Expand a Pocket Base
As a **Pocket Base owner**, I want to purchase additional buildable space so that my pocket world can grow with my projects.
## Acceptance criteria
- [x] Using `/basesettings pocket upgrade` after Pocket Base I purchases the next Pocket Base level.
- [x] Each additional level atomically charges a configurable currency and amount that default to 16 diamond blocks.
- [x] Insufficient funds or failed persistence or terrain generation does not consume currency or grant a level.
- [x] Each level adds 16 blocks on every side, producing Level I at 64 by 64, Level II at 96 by 96, Level III at 128 by 128, and size `64 + 32 × (level - 1)` thereafter.
- [x] Newly unlocked terrain has one grass layer over three dirt layers.
- [x] Expansion preserves all existing blocks, inventories, and entities in previously unlocked terrain.
- [x] Owners and visitors cannot place blocks outside the currently unlocked horizontal boundary.
- [x] Pocket Base progression has no plugin-configured level cap, subject only to Minecraft coordinate and integer limits.
- [x] Pocket Base levels, dimensions, contents, and build boundaries persist through restarts.
## Related
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
- [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
@@ -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>]");
}
@@ -83,6 +83,13 @@ final class BaseTeleportManager implements Listener {
visitor.sendMessage(ChatColor.RED + "That base is not accepting visitors.");
return;
}
BaseLocation destination = owner.base().orElseThrow();
if (!visitor.getWorld().getUID().equals(destination.worldId())) {
visitor.sendMessage(
ChatColor.RED + "You must be in the same dimension as that base to visit it."
);
return;
}
PlayerState visitorState = stateManager.player(visitor.getUniqueId(), visitor.getName());
Optional<Duration> remaining = visitorPolicy.remaining(
visitorState, owner.playerId(), clock.instant()
@@ -94,7 +101,7 @@ final class BaseTeleportManager implements Listener {
}
begin(
visitor,
owner.base().orElseThrow(),
destination,
policy.visitorWarmup(owner),
owner.playerId(),
policy.cooldown(owner),
@@ -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());
}
}
+6
View File
@@ -46,6 +46,12 @@ fourth-teleport-cooldown-seconds: 1800
visitor-unlock-diamond-cost: 128
visitor-currency-material: DIAMOND
# Pocket Base
pocket-base-unlock-cost: 64
pocket-base-upgrade-cost: 16
pocket-base-currency-material: DIAMOND_BLOCK
pocket-base-portal-frame-material: DIAMOND_BLOCK
# Spawnable block overlay
spawnable-overlay-unlock-placements: 250
+1 -1
View File
@@ -19,7 +19,7 @@ commands:
aliases: [visit]
basesettings:
description: View progression, upgrade, and manage base settings.
usage: /basesettings [status|upgrade|visitors|navigation|flight|border|spawnable|bossbar]
usage: /basesettings [status|upgrade|pocket|visitors|navigation|flight|border|spawnable|bossbar]
aliases: [homesettings]
baseadmin:
description: Administer Spigot Base.
@@ -10,6 +10,7 @@ import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@@ -22,6 +23,7 @@ import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
final class BaseSettingsCommandTest {
@Test
@@ -89,6 +91,10 @@ final class BaseSettingsCommandTest {
List.of("allowed"),
command.onTabComplete(null, null, "basesettings", new String[] {"visitors", "a"})
);
assertEquals(
List.of("upgrade"),
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "u"})
);
for (String setting : List.of("navigation", "flight", "border", "spawnable", "bossbar")) {
assertEquals(
List.of("disable"),
@@ -107,7 +113,7 @@ final class BaseSettingsCommandTest {
assertEquals(
List.of(
"status", "upgrade", "visitors", "navigation", "flight", "border",
"status", "upgrade", "pocket", "visitors", "navigation", "flight", "border",
"spawnable", "bossbar"
),
command.onTabComplete(null, null, "basesettings", new String[] {""})
@@ -155,6 +161,81 @@ final class BaseSettingsCommandTest {
assertTrue(updated.get().visitorsEnabled());
}
@Test
void pocketUpgradePurchasesFirstLevelAfterBaseIv() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
PlayerInventory inventory = mock(PlayerInventory.class);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {
new ItemStack(Material.DIAMOND_BLOCK, 64)
});
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(owner);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
when(pocketBases.upgrade(playerId)).thenReturn(
new PocketBaseState(playerId, 1, java.util.Optional.empty())
);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(player, null, "basesettings", new String[] {"pocket", "upgrade"});
verify(pocketBases).upgrade(playerId);
verify(inventory).setStorageContents(any(ItemStack[].class));
}
@Test
void failedPocketUpgradeRestoresPayment() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
PlayerInventory inventory = mock(PlayerInventory.class);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {
new ItemStack(Material.DIAMOND_BLOCK, 64)
});
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(owner);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
when(pocketBases.upgrade(playerId)).thenThrow(new IOException("save failed"));
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(player, null, "basesettings", new String[] {"pocket", "upgrade"});
ArgumentCaptor<ItemStack[]> contents = ArgumentCaptor.forClass(ItemStack[].class);
verify(inventory, times(2)).setStorageContents(contents.capture());
ItemStack[] restored = contents.getAllValues().get(1);
assertEquals(Material.DIAMOND_BLOCK, restored[0].getType());
assertEquals(64, restored[0].getAmount());
}
@Test
void bossbarEnableIsIdempotent() {
UUID playerId = UUID.randomUUID();
@@ -0,0 +1,64 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.junit.jupiter.api.Test;
final class BaseTeleportManagerTest {
@Test
void visitFromAnotherWorldIsRejectedBeforeWarmupAndCooldownChecks() {
UUID visitorId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
UUID baseWorldId = UUID.randomUUID();
BaseStateManager stateManager = mock(BaseStateManager.class);
TeleportPolicy teleportPolicy = mock(TeleportPolicy.class);
VisitorPolicy visitorPolicy = mock(VisitorPolicy.class);
Player visitor = mock(Player.class);
World visitorWorld = mock(World.class);
PlayerState owner = PlayerState.newPlayer(ownerId, "Owner")
.withGrassAndDirtProgress(500, 4)
.withBase(
new BaseLocation(baseWorldId, "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withVisitorsEnabled(true);
PlayerState visitorState = PlayerState.newPlayer(visitorId, "Visitor");
BaseTeleportManager manager = new BaseTeleportManager(
mock(Plugin.class),
stateManager,
teleportPolicy,
visitorPolicy,
mock(SafeBaseDestination.class),
Clock.systemUTC()
);
when(visitor.getUniqueId()).thenReturn(visitorId);
when(visitor.getName()).thenReturn("Visitor");
when(visitor.getWorld()).thenReturn(visitorWorld);
when(visitorWorld.getUID()).thenReturn(UUID.randomUUID());
when(stateManager.player(visitorId, "Visitor")).thenReturn(visitorState);
when(visitorPolicy.remaining(eq(visitorState), eq(ownerId), any()))
.thenReturn(Optional.of(Duration.ofSeconds(1)));
manager.startVisit(visitor, owner);
verify(visitor).sendMessage(
ChatColor.RED + "You must be in the same dimension as that base to visit it."
);
verifyNoInteractions(stateManager, teleportPolicy, visitorPolicy);
}
}
@@ -21,6 +21,25 @@ final class PluginSettingsTest {
assertEquals(250, settings.spawnableOverlayUnlockPlacements());
assertEquals(10_800L, settings.initialTeleportCooldownSeconds());
assertEquals(128, settings.visitorUnlockDiamondCost());
assertEquals(64, settings.pocketBaseUnlockCost());
assertEquals(16, settings.pocketBaseUpgradeCost());
assertEquals("DIAMOND_BLOCK", settings.pocketBaseCurrencyMaterial());
assertEquals("DIAMOND_BLOCK", settings.pocketBasePortalFrameMaterial());
}
@Test
void pocketBaseCostsAndMaterialsAreConfigurable() {
PluginSettings settings = PluginSettings.from(Map.of(
"pocket-base-unlock-cost", 32,
"pocket-base-upgrade-cost", 8,
"pocket-base-currency-material", "EMERALD_BLOCK",
"pocket-base-portal-frame-material", "GOLD_BLOCK"
));
assertEquals(32, settings.pocketBaseUnlockCost());
assertEquals(8, settings.pocketBaseUpgradeCost());
assertEquals("EMERALD_BLOCK", settings.pocketBaseCurrencyMaterial());
assertEquals("GOLD_BLOCK", settings.pocketBasePortalFrameMaterial());
}
@Test
@@ -0,0 +1,136 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.block.Action;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.Test;
final class PocketBaseControllerTest {
@Test
void ownerCanActivateCompleteFrameInsideNormalBase() throws Exception {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PocketPortalLocation portal = new PocketPortalLocation(
worldId, "world", -2, 64, 0, PocketPortalAxis.X
);
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
Player owner = mock(Player.class);
World world = mock(World.class);
Block clicked = block(world, -2, 66, 0, Material.DIAMOND_BLOCK);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
BaseStateManager baseStates = mock(BaseStateManager.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of());
PlayerState ownerState = PlayerState.newPlayer(ownerId, "Owner")
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH);
when(owner.getUniqueId()).thenReturn(ownerId);
when(owner.getName()).thenReturn("Owner");
when(event.getPlayer()).thenReturn(owner);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getClickedBlock()).thenReturn(clicked);
when(event.getItem()).thenReturn(new ItemStack(Material.FLINT_AND_STEEL));
when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("world");
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
BlockPosition position = new BlockPosition(
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
);
return block(
world,
position.x(),
position.y(),
position.z(),
frame.contains(position) ? Material.DIAMOND_BLOCK : Material.AIR
);
});
when(baseStates.player(ownerId, "Owner")).thenReturn(ownerState);
when(pocketBases.state(ownerId)).thenReturn(
new PocketBaseState(ownerId, 1, Optional.empty())
);
when(pocketBases.activateEntrance(ownerId, portal)).thenReturn(
new PocketBaseState(ownerId, 1, Optional.of(portal))
);
PocketBaseController controller = new PocketBaseController(
mock(Server.class),
baseStates,
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger()
);
controller.onActivate(event);
verify(pocketBases).activateEntrance(ownerId, portal);
verify(event).setCancelled(true);
}
@Test
void placementOutsideUnlockedPocketBoundaryIsCancelled() {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
World world = mock(World.class);
Block placed = block(world, 32, 65, 0, Material.STONE);
Player player = mock(Player.class);
BlockPlaceEvent event = mock(BlockPlaceEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of());
when(world.getUID()).thenReturn(worldId);
when(event.getBlockPlaced()).thenReturn(placed);
when(event.getPlayer()).thenReturn(player);
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(
new PocketBaseState(ownerId, 1, Optional.empty())
);
when(pocketBases.policy()).thenReturn(new PocketBasePolicy(settings));
PocketBaseController controller = new PocketBaseController(
mock(Server.class),
mock(BaseStateManager.class),
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger()
);
controller.onPlace(event);
verify(event).setCancelled(true);
}
private static Block block(
World world,
int x,
int y,
int z,
Material material
) {
Block block = mock(Block.class);
when(block.getWorld()).thenReturn(world);
when(block.getX()).thenReturn(x);
when(block.getY()).thenReturn(y);
when(block.getZ()).thenReturn(z);
when(block.getType()).thenReturn(material);
return block;
}
}
@@ -0,0 +1,34 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class PocketBasePolicyTest {
private final PocketBasePolicy policy = new PocketBasePolicy(PluginSettings.from(Map.of()));
@Test
void levelsExpandBySixteenBlocksOnEverySide() {
assertEquals(64, policy.size(1));
assertEquals(96, policy.size(2));
assertEquals(128, policy.size(3));
}
@Test
void unlockAndLaterLevelsUseTheirConfiguredPrices() {
assertEquals(64, policy.price(PocketBaseState.locked(UUID.randomUUID())));
assertEquals(16, policy.price(new PocketBaseState(UUID.randomUUID(), 1, java.util.Optional.empty())));
}
@Test
void buildBoundaryMatchesUnlockedPlatform() {
assertTrue(policy.contains(1, -32, -32));
assertTrue(policy.contains(1, 31, 31));
assertFalse(policy.contains(1, -33, 0));
assertFalse(policy.contains(1, 32, 0));
}
}
@@ -0,0 +1,84 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.WorldCreator;
import org.bukkit.block.Block;
import org.junit.jupiter.api.Test;
final class PocketBaseWorldServiceTest {
@Test
void firstLevelGeneratesGrassPlatformAndReturnPortal() {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
Server server = mock(Server.class);
World world = mock(World.class);
Map<BlockPosition, Block> blocks = blocks(world);
when(server.createWorld(any(WorldCreator.class))).thenReturn(world);
when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("pocket");
PocketBaseWorldService service = new PocketBaseWorldService(
server,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
service.expand(ownerId, 0, 1);
verify(blocks.get(new BlockPosition(-32, 64, -32)))
.setType(Material.GRASS_BLOCK, false);
verify(blocks.get(new BlockPosition(31, 61, 31))).setType(Material.DIRT, false);
verify(blocks.get(new BlockPosition(-2, 65, 0)))
.setType(Material.DIAMOND_BLOCK, false);
verify(world).setSpawnLocation(0, 65, 5);
assertEquals(ownerId, service.ownerForWorld(worldId).orElseThrow());
}
@Test
void laterLevelAddsRingWithoutOverwritingExistingTerrain() {
UUID ownerId = UUID.randomUUID();
Server server = mock(Server.class);
World world = mock(World.class);
Map<BlockPosition, Block> blocks = blocks(world);
when(server.createWorld(any(WorldCreator.class))).thenReturn(world);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.getName()).thenReturn("pocket");
PocketBaseWorldService service = new PocketBaseWorldService(
server,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
Block existingCenter = world.getBlockAt(0, 64, 0);
service.expand(ownerId, 1, 2);
verify(blocks.get(new BlockPosition(47, 64, 47)))
.setType(Material.GRASS_BLOCK, false);
verify(existingCenter, never())
.setType(any(Material.class), org.mockito.ArgumentMatchers.anyBoolean());
}
private static Map<BlockPosition, Block> blocks(World world) {
Map<BlockPosition, Block> blocks = new HashMap<>();
when(world.getBlockAt(
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.anyInt()
)).thenAnswer(invocation -> {
BlockPosition position = new BlockPosition(
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
);
return blocks.computeIfAbsent(position, ignored -> mock(Block.class));
});
return blocks;
}
}
@@ -0,0 +1,42 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class PocketPortalGeometryTest {
@Test
void findsStandardFrameContainingIgnitedBlock() {
UUID worldId = UUID.randomUUID();
PocketPortalLocation expected = new PocketPortalLocation(
worldId, "world", 10, 64, 20, PocketPortalAxis.X
);
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(expected));
Optional<PocketPortalLocation> found = PocketPortalGeometry.findFrame(
new BlockPosition(10, 66, 20),
worldId,
"world",
frame::contains,
ignored -> true
);
assertEquals(expected, found.orElseThrow());
}
@Test
void identifiesPortalInteriorAndRequiredFrameBlocks() {
PocketPortalLocation portal = new PocketPortalLocation(
UUID.randomUUID(), "world", 0, 64, 0, PocketPortalAxis.Z
);
assertTrue(PocketPortalGeometry.isInterior(portal, new BlockPosition(0, 65, 1)));
assertTrue(PocketPortalGeometry.isFrame(portal, new BlockPosition(0, 68, 3)));
assertEquals(14, PocketPortalGeometry.frameBlocks(portal).size());
}
}
@@ -0,0 +1,35 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class YamlPocketBaseRepositoryTest {
@TempDir
Path temporaryDirectory;
@Test
void roundTripsLevelsAndActiveEntrances() throws Exception {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PocketBaseState expected = new PocketBaseState(
ownerId,
7,
Optional.of(new PocketPortalLocation(
worldId, "world", 10, 65, -4, PocketPortalAxis.X
))
);
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(
temporaryDirectory.resolve("pocket-bases.yml")
);
repository.save(Map.of(ownerId, expected));
assertEquals(expected, repository.load().get(ownerId));
}
}