From 11e3ad172083f6a4ef43af9ba513f303b03b96c9 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Fri, 21 Aug 2026 23:16:04 -0400 Subject: [PATCH] feat(pocket-base): add persistent pocket dimensions --- README.md | 3 + design/user-stories/index.md | 3 + .../us-008-unlock-visitor-access.md | 2 + .../us-015-unlock-a-pocket-base.md | 30 ++ ...s-016-build-and-use-pocket-base-portals.md | 35 ++ .../us-017-expand-a-pocket-base.md | 28 ++ .../dmg/spigotbase/BaseSettingsCommand.java | 82 ++++- .../games/dmg/spigotbase/BlockPosition.java | 4 + .../games/dmg/spigotbase/PluginSettings.java | 18 + .../spigotbase/PluginSettingsValidator.java | 16 +- .../dmg/spigotbase/PocketBaseController.java | 312 ++++++++++++++++++ .../dmg/spigotbase/PocketBaseManager.java | 86 +++++ .../dmg/spigotbase/PocketBasePolicy.java | 31 ++ .../games/dmg/spigotbase/PocketBaseState.java | 39 +++ .../spigotbase/PocketBaseStateManager.java | 41 +++ .../spigotbase/PocketBaseWorldService.java | 120 +++++++ .../dmg/spigotbase/PocketPortalAxis.java | 6 + .../dmg/spigotbase/PocketPortalGeometry.java | 83 +++++ .../dmg/spigotbase/PocketPortalLocation.java | 24 ++ .../games/dmg/spigotbase/SetBaseCommand.java | 18 + .../dmg/spigotbase/SpigotBasePlugin.java | 32 +- .../spigotbase/VoidPocketChunkGenerator.java | 35 ++ .../spigotbase/YamlPocketBaseRepository.java | 121 +++++++ src/main/resources/config.yml | 6 + src/main/resources/plugin.yml | 2 +- .../spigotbase/BaseSettingsCommandTest.java | 83 ++++- .../dmg/spigotbase/PluginSettingsTest.java | 19 ++ .../spigotbase/PocketBaseControllerTest.java | 136 ++++++++ .../dmg/spigotbase/PocketBasePolicyTest.java | 34 ++ .../PocketBaseWorldServiceTest.java | 84 +++++ .../spigotbase/PocketPortalGeometryTest.java | 42 +++ .../YamlPocketBaseRepositoryTest.java | 35 ++ 32 files changed, 1597 insertions(+), 13 deletions(-) create mode 100644 design/user-stories/us-015-unlock-a-pocket-base.md create mode 100644 design/user-stories/us-016-build-and-use-pocket-base-portals.md create mode 100644 design/user-stories/us-017-expand-a-pocket-base.md create mode 100644 src/main/java/games/dmg/spigotbase/BlockPosition.java create mode 100644 src/main/java/games/dmg/spigotbase/PocketBaseController.java create mode 100644 src/main/java/games/dmg/spigotbase/PocketBaseManager.java create mode 100644 src/main/java/games/dmg/spigotbase/PocketBasePolicy.java create mode 100644 src/main/java/games/dmg/spigotbase/PocketBaseState.java create mode 100644 src/main/java/games/dmg/spigotbase/PocketBaseStateManager.java create mode 100644 src/main/java/games/dmg/spigotbase/PocketBaseWorldService.java create mode 100644 src/main/java/games/dmg/spigotbase/PocketPortalAxis.java create mode 100644 src/main/java/games/dmg/spigotbase/PocketPortalGeometry.java create mode 100644 src/main/java/games/dmg/spigotbase/PocketPortalLocation.java create mode 100644 src/main/java/games/dmg/spigotbase/VoidPocketChunkGenerator.java create mode 100644 src/main/java/games/dmg/spigotbase/YamlPocketBaseRepository.java create mode 100644 src/test/java/games/dmg/spigotbase/PocketBaseControllerTest.java create mode 100644 src/test/java/games/dmg/spigotbase/PocketBasePolicyTest.java create mode 100644 src/test/java/games/dmg/spigotbase/PocketBaseWorldServiceTest.java create mode 100644 src/test/java/games/dmg/spigotbase/PocketPortalGeometryTest.java create mode 100644 src/test/java/games/dmg/spigotbase/YamlPocketBaseRepositoryTest.java diff --git a/README.md b/README.md index dc85597..90e5c4a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ The plugin JAR is written to `build/libs/`. /basesettings (alias: /homesettings) /basesettings status /basesettings upgrade +/basesettings pocket upgrade /basesettings visitors /basesettings navigation /basesettings flight @@ -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`. diff --git a/design/user-stories/index.md b/design/user-stories/index.md index a46f2fa..df41ba7 100644 --- a/design/user-stories/index.md +++ b/design/user-stories/index.md @@ -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) diff --git a/design/user-stories/us-008-unlock-visitor-access.md b/design/user-stories/us-008-unlock-visitor-access.md index 9938a34..424f9bb 100644 --- a/design/user-stories/us-008-unlock-visitor-access.md +++ b/design/user-stories/us-008-unlock-visitor-access.md @@ -37,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) diff --git a/design/user-stories/us-015-unlock-a-pocket-base.md b/design/user-stories/us-015-unlock-a-pocket-base.md new file mode 100644 index 0000000..39008a4 --- /dev/null +++ b/design/user-stories/us-015-unlock-a-pocket-base.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) diff --git a/design/user-stories/us-016-build-and-use-pocket-base-portals.md b/design/user-stories/us-016-build-and-use-pocket-base-portals.md new file mode 100644 index 0000000..9d4d4d7 --- /dev/null +++ b/design/user-stories/us-016-build-and-use-pocket-base-portals.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) diff --git a/design/user-stories/us-017-expand-a-pocket-base.md b/design/user-stories/us-017-expand-a-pocket-base.md new file mode 100644 index 0000000..34beb23 --- /dev/null +++ b/design/user-stories/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) diff --git a/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java b/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java index 3a9bc64..53767ad 100644 --- a/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java +++ b/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java @@ -15,8 +15,8 @@ import org.bukkit.inventory.PlayerInventory; final class BaseSettingsCommand implements CommandExecutor, TabCompleter { private static final List SETTINGS = List.of( - "status", "upgrade", "visitors", "navigation", "flight", "border", "spawnable", - "bossbar" + "status", "upgrade", "pocket", "visitors", "navigation", "flight", "border", + "spawnable", "bossbar" ); private static final List VISITOR_MODES = List.of("allowed", "blocked"); private static final List 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 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 |navigation " + + "[status|upgrade|pocket upgrade|visitors " + + "|navigation " + "|flight |border " + "|spawnable |bossbar ]"); } diff --git a/src/main/java/games/dmg/spigotbase/BlockPosition.java b/src/main/java/games/dmg/spigotbase/BlockPosition.java new file mode 100644 index 0000000..6068768 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BlockPosition.java @@ -0,0 +1,4 @@ +package games.dmg.spigotbase; + +public record BlockPosition(int x, int y, int z) { +} diff --git a/src/main/java/games/dmg/spigotbase/PluginSettings.java b/src/main/java/games/dmg/spigotbase/PluginSettings.java index 351fa75..92ae8e2 100644 --- a/src/main/java/games/dmg/spigotbase/PluginSettings.java +++ b/src/main/java/games/dmg/spigotbase/PluginSettings.java @@ -37,11 +37,15 @@ public record PluginSettings( long thirdTeleportCooldownSeconds, long fourthTeleportCooldownSeconds, int visitorUnlockDiamondCost, + int pocketBaseUnlockCost, + int pocketBaseUpgradeCost, Set baseUnlockMaterials, String stoneExpansionMaterial, String deepslateExpansionMaterial, String obsidianExpansionMaterial, String visitorCurrencyMaterial, + String pocketBaseCurrencyMaterial, + String pocketBasePortalFrameMaterial, Set 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), diff --git a/src/main/java/games/dmg/spigotbase/PluginSettingsValidator.java b/src/main/java/games/dmg/spigotbase/PluginSettingsValidator.java index 78451fa..6bf40d9 100644 --- a/src/main/java/games/dmg/spigotbase/PluginSettingsValidator.java +++ b/src/main/java/games/dmg/spigotbase/PluginSettingsValidator.java @@ -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"); + } + } } diff --git a/src/main/java/games/dmg/spigotbase/PocketBaseController.java b/src/main/java/games/dmg/spigotbase/PocketBaseController.java new file mode 100644 index 0000000..cb7f51f --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/PocketBaseController.java @@ -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 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 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 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 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 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()); + } +} diff --git a/src/main/java/games/dmg/spigotbase/PocketBaseManager.java b/src/main/java/games/dmg/spigotbase/PocketBaseManager.java new file mode 100644 index 0000000..653773d --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/PocketBaseManager.java @@ -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 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 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; + } +} diff --git a/src/main/java/games/dmg/spigotbase/PocketBasePolicy.java b/src/main/java/games/dmg/spigotbase/PocketBasePolicy.java new file mode 100644 index 0000000..0c578b7 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/PocketBasePolicy.java @@ -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; + } +} diff --git a/src/main/java/games/dmg/spigotbase/PocketBaseState.java b/src/main/java/games/dmg/spigotbase/PocketBaseState.java new file mode 100644 index 0000000..12525ae --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/PocketBaseState.java @@ -0,0 +1,39 @@ +package games.dmg.spigotbase; + +import java.util.Optional; +import java.util.UUID; + +public record PocketBaseState( + UUID ownerId, + int level, + Optional 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()); + } +} diff --git a/src/main/java/games/dmg/spigotbase/PocketBaseStateManager.java b/src/main/java/games/dmg/spigotbase/PocketBaseStateManager.java new file mode 100644 index 0000000..9f76909 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/PocketBaseStateManager.java @@ -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 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 knownStates() { + return Map.copyOf(states); + } + + PocketBaseState updateAndSave( + UUID ownerId, + UnaryOperator operation + ) throws IOException { + PocketBaseState updated = operation.apply(state(ownerId)); + if (!updated.ownerId().equals(ownerId)) { + throw new IllegalArgumentException("Pocket Base owner ID cannot change"); + } + Map proposed = new HashMap<>(states); + proposed.put(ownerId, updated); + repository.save(proposed); + states.clear(); + states.putAll(proposed); + return updated; + } +} diff --git a/src/main/java/games/dmg/spigotbase/PocketBaseWorldService.java b/src/main/java/games/dmg/spigotbase/PocketBaseWorldService.java new file mode 100644 index 0000000..806f20f --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/PocketBaseWorldService.java @@ -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 ownersByWorld = new HashMap<>(); + + PocketBaseWorldService(Server server, PluginSettingsProvider settings) { + this.server = server; + this.settings = settings; + this.policy = new PocketBasePolicy(settings); + } + + void loadExisting(Map 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 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("-", ""); + } +} diff --git a/src/main/java/games/dmg/spigotbase/PocketPortalAxis.java b/src/main/java/games/dmg/spigotbase/PocketPortalAxis.java new file mode 100644 index 0000000..0248d63 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/PocketPortalAxis.java @@ -0,0 +1,6 @@ +package games.dmg.spigotbase; + +public enum PocketPortalAxis { + X, + Z +} diff --git a/src/main/java/games/dmg/spigotbase/PocketPortalGeometry.java b/src/main/java/games/dmg/spigotbase/PocketPortalGeometry.java new file mode 100644 index 0000000..fa9ccf0 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/PocketPortalGeometry.java @@ -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 findFrame( + BlockPosition clicked, + UUID worldId, + String worldName, + Predicate isFrameMaterial, + Predicate 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 frameBlocks(PocketPortalLocation portal) { + List 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 interiorBlocks(PocketPortalLocation portal) { + List 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); + } +} diff --git a/src/main/java/games/dmg/spigotbase/PocketPortalLocation.java b/src/main/java/games/dmg/spigotbase/PocketPortalLocation.java new file mode 100644 index 0000000..6e91b4d --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/PocketPortalLocation.java @@ -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"); + } + } +} diff --git a/src/main/java/games/dmg/spigotbase/SetBaseCommand.java b/src/main/java/games/dmg/spigotbase/SetBaseCommand.java index c9bbf8e..46807ff 100644 --- a/src/main/java/games/dmg/spigotbase/SetBaseCommand.java +++ b/src/main/java/games/dmg/spigotbase/SetBaseCommand.java @@ -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; } diff --git a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java index 831a590..751db1f 100644 --- a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java +++ b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java @@ -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."); } diff --git a/src/main/java/games/dmg/spigotbase/VoidPocketChunkGenerator.java b/src/main/java/games/dmg/spigotbase/VoidPocketChunkGenerator.java new file mode 100644 index 0000000..826b4f7 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/VoidPocketChunkGenerator.java @@ -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; + } +} diff --git a/src/main/java/games/dmg/spigotbase/YamlPocketBaseRepository.java b/src/main/java/games/dmg/spigotbase/YamlPocketBaseRepository.java new file mode 100644 index 0000000..97ae722 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/YamlPocketBaseRepository.java @@ -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 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 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 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 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()); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index c63220e..3c4396c 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -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 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 74ef368..ee84320 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -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. diff --git a/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java b/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java index be3b296..88d7374 100644 --- a/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java +++ b/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java @@ -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 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(); diff --git a/src/test/java/games/dmg/spigotbase/PluginSettingsTest.java b/src/test/java/games/dmg/spigotbase/PluginSettingsTest.java index b49ce57..f0a234e 100644 --- a/src/test/java/games/dmg/spigotbase/PluginSettingsTest.java +++ b/src/test/java/games/dmg/spigotbase/PluginSettingsTest.java @@ -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 diff --git a/src/test/java/games/dmg/spigotbase/PocketBaseControllerTest.java b/src/test/java/games/dmg/spigotbase/PocketBaseControllerTest.java new file mode 100644 index 0000000..72bb4cf --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/PocketBaseControllerTest.java @@ -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 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; + } +} diff --git a/src/test/java/games/dmg/spigotbase/PocketBasePolicyTest.java b/src/test/java/games/dmg/spigotbase/PocketBasePolicyTest.java new file mode 100644 index 0000000..9092843 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/PocketBasePolicyTest.java @@ -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)); + } +} diff --git a/src/test/java/games/dmg/spigotbase/PocketBaseWorldServiceTest.java b/src/test/java/games/dmg/spigotbase/PocketBaseWorldServiceTest.java new file mode 100644 index 0000000..4ceb186 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/PocketBaseWorldServiceTest.java @@ -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 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 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 blocks(World world) { + Map 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; + } +} diff --git a/src/test/java/games/dmg/spigotbase/PocketPortalGeometryTest.java b/src/test/java/games/dmg/spigotbase/PocketPortalGeometryTest.java new file mode 100644 index 0000000..14153f9 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/PocketPortalGeometryTest.java @@ -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 frame = new HashSet<>(PocketPortalGeometry.frameBlocks(expected)); + + Optional 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()); + } +} diff --git a/src/test/java/games/dmg/spigotbase/YamlPocketBaseRepositoryTest.java b/src/test/java/games/dmg/spigotbase/YamlPocketBaseRepositoryTest.java new file mode 100644 index 0000000..f38039b --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/YamlPocketBaseRepositoryTest.java @@ -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)); + } +}