4 Commits
Author SHA1 Message Date
dmg 6ea5bc16f4 feat(pocket-base): add purchasable biome changes
Release / release (push) Successful in 2m54s
CI / build (push) Successful in 1m16s
2026-08-24 10:32:32 -04:00
dmg 4518478ea1 feat(pocket-base): allow return portal relocation
Release / release (push) Successful in 2m39s
CI / build (push) Successful in 1m13s
2026-08-24 10:06:25 -04:00
dmg 23e8fcec6e feat(pocket-base): add mob spawning toggle
Release / release (push) Successful in 2m46s
CI / build (push) Successful in 1m28s
2026-08-24 09:57:48 -04:00
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
38 changed files with 2803 additions and 13 deletions
+7
View File
@@ -28,6 +28,9 @@ The plugin JAR is written to `build/libs/`.
/basesettings (alias: /homesettings)
/basesettings status
/basesettings upgrade
/basesettings pocket upgrade
/basesettings pocket mobs <enable|disable>
/basesettings pocket type <void|nether|overworld> <subtype>
/basesettings visitors <allowed|blocked>
/basesettings navigation <enable|disable>
/basesettings flight <enable|disable>
@@ -42,6 +45,10 @@ 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. The owner can move the return portal by building and igniting another complete frame inside the unlocked Pocket Base boundary; only the newly activated return portal remains functional. Natural hostile and passive mob spawning is disabled by default and can be controlled by the owner with `/basesettings pocket mobs <enable|disable>`.
Owners can change biome metadata without altering Pocket Base blocks, entities, inventories, or portals by using `/basesettings pocket type <void|nether|overworld> <subtype>`. Void uses `the_void`; Nether and Overworld accept their compatible vanilla biome names, such as `crimson_forest` or `plains`. Each change defaults to 16 netherite blocks, is configurable, and affects applicable natural mob selection when spawning is enabled. The Pocket Base remains a void-generated normal-environment world; dimension-specific mechanics do not change.
## Administration
The `spigotbase.admin` permission is granted to server operators by default. `/homeadmin` aliases `/baseadmin`.
+22
View File
@@ -91,3 +91,25 @@ description: Chronological record of material decisions affecting the Spigot Bas
- Used the live size-tier radius so expansion and relocation immediately update the visibility threshold.
- Added exact-boundary and controller integration coverage.
- Verified the change with `./gradlew clean check jar`.
## 2026-08-24 — Pocket Base mob-spawning control
- Disabled natural hostile and passive mob spawning by default in new and existing Pocket Bases without a saved preference.
- Added the persisted owner setting `/basesettings pocket mobs enable|disable`, contextual autocomplete, and status reporting.
- Reapplied saved spawn flags when Pocket Base worlds load and restored the prior world setting when persistence fails.
- Verified the feature with `./gradlew clean check jar`.
## 2026-08-24 — Relocatable Pocket Base return portals
- Allowed Pocket Base owners to activate a complete return portal frame with flint and steel anywhere inside their unlocked pocket boundary.
- Persisted one active return portal location while retaining the generated portal as the default for existing Pocket Bases.
- Made only the selected intact frame functional without removing old frame blocks, preserving the void-return safety fallback.
- Verified the feature with `./gradlew clean check jar`.
## 2026-08-24 — Purchasable Pocket Base biomes
- Added `/basesettings pocket type <void|nether|overworld> <subtype>` with type-aware autocomplete and validation.
- Defaulted new and unconfigured Pocket Bases to `void/the_void` and persisted each owner's selected biome.
- Applied biome metadata across unlocked columns and new expansion rings without changing blocks, entities, inventories, portals, progression, or mob-spawning preferences.
- Added an atomic, configurable biome-change payment that defaults to 16 netherite blocks and restores the prior biome and payment on failure.
- Verified the feature with `./gradlew clean check jar`.
+5
View File
@@ -20,3 +20,8 @@ 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)
18. [US-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md)
19. [US-019: Change a Pocket Base biome](us-019-change-a-pocket-base-biome.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)
@@ -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,40 @@
---
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] Only the owner can activate a complete portal frame inside their own Pocket Base with flint and steel.
- [x] Every required return-frame block must be within the owner's unlocked Pocket Base boundary.
- [x] Each Pocket Base has at most one active return portal, and activating another valid frame moves the active return location without removing the old frame.
- [x] Relocated return portals persist through restarts, while existing Pocket Bases use the generated portal as their default.
- [x] Breaking the active return frame disables return travel through it.
- [x] Return travel falls back to the server spawn when the owner has no valid normal-world 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)
@@ -0,0 +1,29 @@
---
type: User Story
title: "US-018: Control Pocket Base mob spawning"
description: Let Pocket Base owners control natural mob spawning in their private world, with spawning disabled by default.
status: done
---
# US-018: Control Pocket Base mob spawning
As a **Pocket Base owner**, I want to enable or disable natural mob spawning in my Pocket Base so that I can choose whether the private world supports normal mob activity.
## Acceptance criteria
- [x] Natural mob spawning is disabled by default in every new Pocket Base and in existing Pocket Bases that have no saved preference.
- [x] A Pocket Base owner can use `/basesettings pocket mobs enable|disable` to control natural mob spawning in their own Pocket Base.
- [x] The setting controls both hostile and passive natural spawning only in the owner's Pocket Base.
- [x] The command is unavailable until Pocket Base I is unlocked.
- [x] The command and its modes are offered through contextual autocomplete.
- [x] `/basesettings status` displays the current Pocket Base mob-spawning setting.
- [x] The preference persists across restarts and is reapplied when the Pocket Base world loads.
- [x] Disabling natural spawning does not remove existing mobs or prevent explicitly spawned or summoned mobs.
- [x] Persistence or world-application failures produce a clear failure message without reporting a successful change.
## Related
- [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)
- [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
- [US-019: Change a Pocket Base biome](us-019-change-a-pocket-base-biome.md)
@@ -0,0 +1,31 @@
---
type: User Story
title: "US-019: Change a Pocket Base biome"
description: Let Pocket Base owners purchase biome changes that alter natural mob selection without changing their builds.
status: done
---
# US-019: Change a Pocket Base biome
As a **Pocket Base owner**, I want to change my Pocket Base biome so that I can control its biome-specific atmosphere and natural mob selection without rebuilding it.
## Acceptance criteria
- [x] A Pocket Base owner can use `/basesettings pocket type <void|nether|overworld> <subtype>` to select a compatible biome.
- [x] Void supports `the_void`; Nether supports its vanilla biome subtypes; and Overworld supports its vanilla biome subtypes.
- [x] New and previously unconfigured Pocket Bases default to the void type and `the_void` subtype.
- [x] Each change atomically charges a configurable currency and amount that default to 16 netherite blocks.
- [x] Selecting the active subtype or providing an invalid type/subtype combination is rejected without charging.
- [x] Changing subtypes within the same type incurs the configured cost.
- [x] The selected biome is applied across every unlocked Pocket Base column without changing blocks, entities, inventories, portals, level, or the mob-spawning preference.
- [x] Newly unlocked terrain receives the selected biome.
- [x] When natural mob spawning is enabled, Minecraft can use the selected biome for applicable natural mob selection.
- [x] The selected type and subtype persist through restarts and appear in `/basesettings status`.
- [x] The command, types, and compatible subtypes are offered through contextual autocomplete.
- [x] Failed biome application or persistence restores the prior biome and does not consume payment.
## Related
- [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)
- [US-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md)
@@ -15,25 +15,39 @@ 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");
private static final List<String> POCKET_WORLD_TYPES = List.of(
"void", "nether", "overworld"
);
private final BaseStateManager stateManager;
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 +65,18 @@ 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 == 3 && arguments[0].equalsIgnoreCase("pocket")
&& arguments[1].equalsIgnoreCase("mobs")) {
return updatePocketMobSpawning(player, arguments[2]);
}
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("pocket")
&& arguments[1].equalsIgnoreCase("type")) {
return purchasePocketBiome(player, arguments[2], arguments[3]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("visitors")) {
return updateVisitors(player, state, arguments[1]);
}
@@ -114,6 +140,137 @@ 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 purchasePocketBiome(
Player player,
String worldTypeName,
String biomeName
) {
if (pocketBases == null) {
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
return true;
}
PocketBaseState current = pocketBases.state(player.getUniqueId());
if (current.level() < 1) {
player.sendMessage(ChatColor.RED + "Pocket Base I is still locked.");
return true;
}
PocketBaseBiome biome = PocketBaseWorldType.fromCommand(worldTypeName)
.flatMap(worldType -> PocketBaseBiome.fromCommand(worldType, biomeName))
.orElse(null);
if (biome == null) {
player.sendMessage(ChatColor.RED + "That Pocket Base type and subtype are invalid.");
return true;
}
if (biome == current.biome()) {
player.sendMessage(ChatColor.RED + "Your Pocket Base already uses "
+ biome.commandName() + ".");
return true;
}
int price = settings.current().pocketBaseBiomeChangeCost();
Material currency = Material.valueOf(
settings.current().pocketBaseBiomeCurrencyMaterial()
);
PlayerInventory inventory = player.getInventory();
if (countCurrency(inventory, currency) < price) {
player.sendMessage(ChatColor.RED + "Changing Pocket Base biome costs "
+ price + " " + currency.name().toLowerCase(Locale.ROOT) + ".");
return true;
}
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
removeCurrency(inventory, currency, price);
final PocketBaseState updated;
try {
updated = pocketBases.setBiome(player.getUniqueId(), biome);
} catch (IOException | RuntimeException exception) {
inventory.setStorageContents(snapshot);
player.sendMessage(ChatColor.RED
+ "The Pocket Base biome change failed; your payment was restored.");
return true;
}
player.sendMessage(ChatColor.GREEN + "Pocket Base biome changed to "
+ updated.biome().commandName() + " for " + price + " "
+ currency.name().toLowerCase(Locale.ROOT) + ".");
return true;
}
private boolean updatePocketMobSpawning(Player player, String mode) {
if (pocketBases == null) {
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
return true;
}
PocketBaseState current = pocketBases.state(player.getUniqueId());
if (current.level() < 1) {
player.sendMessage(ChatColor.RED + "Pocket Base I is still locked.");
return true;
}
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
final PocketBaseState updated;
try {
updated = pocketBases.setMobSpawning(player.getUniqueId(), enabled);
} catch (IOException | RuntimeException exception) {
player.sendMessage(ChatColor.RED
+ "The Pocket Base mob-spawning setting could not be changed.");
return true;
}
player.sendMessage(ChatColor.YELLOW + "Pocket Base mob spawning is now "
+ (updated.mobSpawningEnabled()
? ChatColor.GREEN + "enabled"
: ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
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 +431,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 +502,22 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
+ DurationFormatter.friendly(teleportPolicy.warmup(state)) + "; " + detail);
}
private void showPocketPath(Player player, PlayerState owner) {
PocketBaseState pocket = pocketBases == null
? PocketBaseState.locked(owner.playerId())
: pocketBases.state(owner.playerId());
int price = pocket.level() == 0
? settings.current().pocketBaseUnlockCost()
: settings.current().pocketBaseUpgradeCost();
player.sendMessage(colorForPrerequisite(owner.baseLevel() >= 4 && owner.base().isPresent())
+ "Pocket Base " + pocket.level() + ": " + ChatColor.GRAY + price + " "
+ settings.current().pocketBaseCurrencyMaterial().toLowerCase(Locale.ROOT)
+ " → /basesettings pocket upgrade; type="
+ pocket.biome().worldType().commandName() + "/" + pocket.biome().commandName()
+ "; mob spawning="
+ (pocket.mobSpawningEnabled() ? "enabled" : "disabled"));
}
private void showCooldownPath(Player player, PlayerState state) {
String detail = switch (state.cooldownLevel()) {
case 0 -> state.blocksBrokenInBase() + "/" + settings.current().firstCooldownBreaks();
@@ -373,12 +547,34 @@ 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", "mobs", "type");
case "navigation", "flight", "border", "spawnable", "bossbar" -> ENABLE_MODES;
default -> List.of();
};
String prefix = arguments[1].toLowerCase(Locale.ROOT);
return modes.stream().filter(mode -> mode.startsWith(prefix)).toList();
}
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("pocket")) {
String prefix = arguments[2].toLowerCase(Locale.ROOT);
if (arguments[1].equalsIgnoreCase("mobs")) {
return ENABLE_MODES.stream().filter(mode -> mode.startsWith(prefix)).toList();
}
if (arguments[1].equalsIgnoreCase("type")) {
return POCKET_WORLD_TYPES.stream()
.filter(type -> type.startsWith(prefix))
.toList();
}
}
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("pocket")
&& arguments[1].equalsIgnoreCase("type")) {
String prefix = arguments[3].toLowerCase(Locale.ROOT);
return PocketBaseWorldType.fromCommand(arguments[2])
.map(PocketBaseBiome::commandNames)
.orElse(List.of())
.stream()
.filter(biome -> biome.startsWith(prefix))
.toList();
}
return List.of();
}
@@ -438,7 +634,9 @@ 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|pocket mobs <enable|disable>"
+ "|pocket type <void|nether|overworld> <subtype>"
+ "|visitors <allowed|blocked>|navigation <enable|disable>"
+ "|flight <enable|disable>|border <enable|disable>"
+ "|spawnable <enable|disable>|bossbar <enable|disable>]");
}
@@ -0,0 +1,4 @@
package games.dmg.spigotbase;
public record BlockPosition(int x, int y, int z) {
}
@@ -37,11 +37,17 @@ public record PluginSettings(
long thirdTeleportCooldownSeconds,
long fourthTeleportCooldownSeconds,
int visitorUnlockDiamondCost,
int pocketBaseUnlockCost,
int pocketBaseUpgradeCost,
int pocketBaseBiomeChangeCost,
Set<String> baseUnlockMaterials,
String stoneExpansionMaterial,
String deepslateExpansionMaterial,
String obsidianExpansionMaterial,
String visitorCurrencyMaterial,
String pocketBaseCurrencyMaterial,
String pocketBaseBiomeCurrencyMaterial,
String pocketBasePortalFrameMaterial,
Set<String> cooldownExcludedMaterials,
int bossBarDurationTicks,
int navigationParticleCount,
@@ -79,6 +85,9 @@ 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_POCKET_BASE_BIOME_CHANGE_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 +149,9 @@ 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");
requirePositive(pocketBaseBiomeChangeCost, "pocket-base-biome-change-cost");
baseUnlockMaterials = normalizedSet(baseUnlockMaterials, "base-unlock-materials", false);
stoneExpansionMaterial = normalizedName(stoneExpansionMaterial, "stone-expansion-material");
deepslateExpansionMaterial = normalizedName(
@@ -151,6 +163,15 @@ public record PluginSettings(
visitorCurrencyMaterial = normalizedName(
visitorCurrencyMaterial, "visitor-currency-material"
);
pocketBaseCurrencyMaterial = normalizedName(
pocketBaseCurrencyMaterial, "pocket-base-currency-material"
);
pocketBaseBiomeCurrencyMaterial = normalizedName(
pocketBaseBiomeCurrencyMaterial, "pocket-base-biome-currency-material"
);
pocketBasePortalFrameMaterial = normalizedName(
pocketBasePortalFrameMaterial, "pocket-base-portal-frame-material"
);
cooldownExcludedMaterials = normalizedSet(
cooldownExcludedMaterials, "cooldown-excluded-materials", true
);
@@ -214,11 +235,21 @@ 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),
integer(
values,
"pocket-base-biome-change-cost",
DEFAULT_POCKET_BASE_BIOME_CHANGE_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-biome-currency-material", "NETHERITE_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,19 @@ 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");
validateCurrency(
settings.pocketBaseBiomeCurrencyMaterial(),
"pocket-base-biome-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,105 @@
package games.dmg.spigotbase;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
public enum PocketBaseBiome {
THE_VOID(PocketBaseWorldType.VOID),
NETHER_WASTES(PocketBaseWorldType.NETHER),
SOUL_SAND_VALLEY(PocketBaseWorldType.NETHER),
CRIMSON_FOREST(PocketBaseWorldType.NETHER),
WARPED_FOREST(PocketBaseWorldType.NETHER),
BASALT_DELTAS(PocketBaseWorldType.NETHER),
OCEAN(PocketBaseWorldType.OVERWORLD),
PLAINS(PocketBaseWorldType.OVERWORLD),
DESERT(PocketBaseWorldType.OVERWORLD),
WINDSWEPT_HILLS(PocketBaseWorldType.OVERWORLD),
FOREST(PocketBaseWorldType.OVERWORLD),
TAIGA(PocketBaseWorldType.OVERWORLD),
SWAMP(PocketBaseWorldType.OVERWORLD),
MANGROVE_SWAMP(PocketBaseWorldType.OVERWORLD),
RIVER(PocketBaseWorldType.OVERWORLD),
FROZEN_OCEAN(PocketBaseWorldType.OVERWORLD),
FROZEN_RIVER(PocketBaseWorldType.OVERWORLD),
SNOWY_PLAINS(PocketBaseWorldType.OVERWORLD),
MUSHROOM_FIELDS(PocketBaseWorldType.OVERWORLD),
BEACH(PocketBaseWorldType.OVERWORLD),
JUNGLE(PocketBaseWorldType.OVERWORLD),
SPARSE_JUNGLE(PocketBaseWorldType.OVERWORLD),
DEEP_OCEAN(PocketBaseWorldType.OVERWORLD),
STONY_SHORE(PocketBaseWorldType.OVERWORLD),
SNOWY_BEACH(PocketBaseWorldType.OVERWORLD),
BIRCH_FOREST(PocketBaseWorldType.OVERWORLD),
DARK_FOREST(PocketBaseWorldType.OVERWORLD),
PALE_GARDEN(PocketBaseWorldType.OVERWORLD),
SNOWY_TAIGA(PocketBaseWorldType.OVERWORLD),
OLD_GROWTH_PINE_TAIGA(PocketBaseWorldType.OVERWORLD),
WINDSWEPT_FOREST(PocketBaseWorldType.OVERWORLD),
SAVANNA(PocketBaseWorldType.OVERWORLD),
SAVANNA_PLATEAU(PocketBaseWorldType.OVERWORLD),
BADLANDS(PocketBaseWorldType.OVERWORLD),
WOODED_BADLANDS(PocketBaseWorldType.OVERWORLD),
WARM_OCEAN(PocketBaseWorldType.OVERWORLD),
LUKEWARM_OCEAN(PocketBaseWorldType.OVERWORLD),
COLD_OCEAN(PocketBaseWorldType.OVERWORLD),
DEEP_LUKEWARM_OCEAN(PocketBaseWorldType.OVERWORLD),
DEEP_COLD_OCEAN(PocketBaseWorldType.OVERWORLD),
DEEP_FROZEN_OCEAN(PocketBaseWorldType.OVERWORLD),
SUNFLOWER_PLAINS(PocketBaseWorldType.OVERWORLD),
WINDSWEPT_GRAVELLY_HILLS(PocketBaseWorldType.OVERWORLD),
FLOWER_FOREST(PocketBaseWorldType.OVERWORLD),
ICE_SPIKES(PocketBaseWorldType.OVERWORLD),
OLD_GROWTH_BIRCH_FOREST(PocketBaseWorldType.OVERWORLD),
OLD_GROWTH_SPRUCE_TAIGA(PocketBaseWorldType.OVERWORLD),
WINDSWEPT_SAVANNA(PocketBaseWorldType.OVERWORLD),
ERODED_BADLANDS(PocketBaseWorldType.OVERWORLD),
BAMBOO_JUNGLE(PocketBaseWorldType.OVERWORLD),
DRIPSTONE_CAVES(PocketBaseWorldType.OVERWORLD),
LUSH_CAVES(PocketBaseWorldType.OVERWORLD),
DEEP_DARK(PocketBaseWorldType.OVERWORLD),
SULFUR_CAVES(PocketBaseWorldType.OVERWORLD),
MEADOW(PocketBaseWorldType.OVERWORLD),
GROVE(PocketBaseWorldType.OVERWORLD),
SNOWY_SLOPES(PocketBaseWorldType.OVERWORLD),
FROZEN_PEAKS(PocketBaseWorldType.OVERWORLD),
JAGGED_PEAKS(PocketBaseWorldType.OVERWORLD),
STONY_PEAKS(PocketBaseWorldType.OVERWORLD),
CHERRY_GROVE(PocketBaseWorldType.OVERWORLD);
private final PocketBaseWorldType worldType;
PocketBaseBiome(PocketBaseWorldType worldType) {
this.worldType = worldType;
}
public PocketBaseWorldType worldType() {
return worldType;
}
public String commandName() {
return name().toLowerCase(Locale.ROOT);
}
static Optional<PocketBaseBiome> fromCommand(
PocketBaseWorldType worldType,
String value
) {
try {
PocketBaseBiome biome = valueOf(value.toUpperCase(Locale.ROOT));
return biome.worldType == worldType ? Optional.of(biome) : Optional.empty();
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
static List<String> commandNames(PocketBaseWorldType worldType) {
return Arrays.stream(values())
.filter(biome -> biome.worldType == worldType)
.map(PocketBaseBiome::commandName)
.toList();
}
}
@@ -0,0 +1,339 @@
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;
}
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;
}
Optional<UUID> pocketOwner = pocketBases.ownerForPocketWorld(world.getUID());
if (pocketOwner.isPresent()) {
if (!pocketOwner.orElseThrow().equals(player.getUniqueId())) {
return;
}
if (PocketPortalGeometry.frameBlocks(portal.orElseThrow()).stream()
.anyMatch(candidate -> !pocketBases.policy().contains(
pocket.level(), candidate.x(), candidate.z()
))) {
player.sendMessage(ChatColor.RED
+ "The complete return portal must be inside your Pocket Base boundary.");
event.setCancelled(true);
return;
}
try {
pocketBases.activateReturnPortal(player.getUniqueId(), portal.orElseThrow());
} catch (IOException | RuntimeException exception) {
logger.log(Level.SEVERE, "Could not activate Pocket Base return portal", exception);
player.sendMessage(ChatColor.RED
+ "The Pocket Base return portal could not be activated.");
event.setCancelled(true);
return;
}
event.setCancelled(true);
player.sendMessage(ChatColor.GREEN + "Pocket Base return portal activated.");
return;
}
PlayerState owner = baseStates.player(player.getUniqueId(), player.getName());
if (owner.baseLevel() < 4 || owner.base().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,141 @@
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, current.biome());
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);
}
}
PocketBaseState activateReturnPortal(
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.withReturnPortal(portal));
}
PocketBaseState setBiome(UUID ownerId, PocketBaseBiome biome) throws IOException {
PocketBaseState current = states.state(ownerId);
if (current.level() < 1) {
throw new IllegalStateException("Pocket Base I is still locked");
}
if (current.biome() == biome) {
throw new IllegalArgumentException("Pocket Base already uses that biome");
}
try {
worlds.setBiome(ownerId, current.level(), biome);
return states.updateAndSave(ownerId, state -> state.withBiome(biome));
} catch (IOException | RuntimeException exception) {
try {
worlds.setBiome(ownerId, current.level(), current.biome());
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
throw exception;
}
}
PocketBaseState setMobSpawning(UUID ownerId, boolean enabled) throws IOException {
PocketBaseState current = states.state(ownerId);
if (current.level() < 1) {
throw new IllegalStateException("Pocket Base I is still locked");
}
worlds.setMobSpawning(ownerId, enabled);
try {
return states.updateAndSave(
ownerId,
state -> state.withMobSpawningEnabled(enabled)
);
} catch (IOException | RuntimeException exception) {
try {
worlds.setMobSpawning(ownerId, current.mobSpawningEnabled());
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
throw exception;
}
}
Optional<UUID> ownerForPocketWorld(UUID worldId) {
return worlds.ownerForWorld(worldId);
}
Location pocketArrival(UUID ownerId) {
return worlds.arrival(ownerId);
}
PocketPortalLocation returnPortal(UUID ownerId) {
return states.state(ownerId).returnPortal().orElseGet(
() -> worlds.returnPortal(ownerId)
);
}
boolean returnPortalIsIntact(UUID ownerId) {
return worlds.returnPortalIsIntact(ownerId, returnPortal(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,104 @@
package games.dmg.spigotbase;
import java.util.Optional;
import java.util.UUID;
public record PocketBaseState(
UUID ownerId,
int level,
Optional<PocketPortalLocation> entrance,
Optional<PocketPortalLocation> returnPortal,
boolean mobSpawningEnabled,
PocketBaseBiome biome
) {
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;
returnPortal = returnPortal == null ? Optional.empty() : returnPortal;
biome = biome == null ? PocketBaseBiome.THE_VOID : biome;
if (level == 0 && (entrance.isPresent() || returnPortal.isPresent())) {
throw new IllegalArgumentException("a locked Pocket Base cannot have a portal");
}
}
public PocketBaseState(
UUID ownerId,
int level,
Optional<PocketPortalLocation> entrance
) {
this(
ownerId, level, entrance, Optional.empty(), false, PocketBaseBiome.THE_VOID
);
}
public PocketBaseState(
UUID ownerId,
int level,
Optional<PocketPortalLocation> entrance,
boolean mobSpawningEnabled
) {
this(
ownerId, level, entrance, Optional.empty(), mobSpawningEnabled,
PocketBaseBiome.THE_VOID
);
}
public PocketBaseState(
UUID ownerId,
int level,
Optional<PocketPortalLocation> entrance,
Optional<PocketPortalLocation> returnPortal,
boolean mobSpawningEnabled
) {
this(
ownerId, level, entrance, returnPortal, mobSpawningEnabled,
PocketBaseBiome.THE_VOID
);
}
public static PocketBaseState locked(UUID ownerId) {
return new PocketBaseState(
ownerId, 0, Optional.empty(), Optional.empty(), false,
PocketBaseBiome.THE_VOID
);
}
public PocketBaseState withLevel(int newLevel) {
return new PocketBaseState(
ownerId, newLevel, entrance, returnPortal, mobSpawningEnabled, biome
);
}
public PocketBaseState withEntrance(PocketPortalLocation portal) {
return new PocketBaseState(
ownerId, level, Optional.of(portal), returnPortal, mobSpawningEnabled, biome
);
}
public PocketBaseState withoutEntrance() {
return new PocketBaseState(
ownerId, level, Optional.empty(), returnPortal, mobSpawningEnabled, biome
);
}
public PocketBaseState withReturnPortal(PocketPortalLocation portal) {
return new PocketBaseState(
ownerId, level, entrance, Optional.of(portal), mobSpawningEnabled, biome
);
}
public PocketBaseState withMobSpawningEnabled(boolean enabled) {
return new PocketBaseState(ownerId, level, entrance, returnPortal, enabled, biome);
}
public PocketBaseState withBiome(PocketBaseBiome selectedBiome) {
return new PocketBaseState(
ownerId, level, entrance, returnPortal, mobSpawningEnabled, selectedBiome
);
}
}
@@ -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,195 @@
package games.dmg.spigotbase;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Registry;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.WorldCreator;
import org.bukkit.block.Biome;
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 Function<PocketBaseBiome, Biome> biomeResolver;
private final Map<UUID, UUID> ownersByWorld = new HashMap<>();
PocketBaseWorldService(Server server, PluginSettingsProvider settings) {
this(server, settings, biome -> resolveBiome(server, biome));
}
PocketBaseWorldService(
Server server,
PluginSettingsProvider settings,
Function<PocketBaseBiome, Biome> biomeResolver
) {
this.server = server;
this.settings = settings;
this.policy = new PocketBasePolicy(settings);
this.biomeResolver = biomeResolver;
}
void loadExisting(Map<UUID, PocketBaseState> states) {
for (PocketBaseState state : states.values()) {
if (state.level() > 0) {
setBiome(state.ownerId(), state.level(), state.biome());
setMobSpawning(state.ownerId(), state.mobSpawningEnabled());
}
}
}
World expand(UUID ownerId, int previousLevel, int newLevel) {
return expand(ownerId, previousLevel, newLevel, PocketBaseBiome.THE_VOID);
}
World expand(
UUID ownerId,
int previousLevel,
int newLevel,
PocketBaseBiome biome
) {
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);
}
}
applyBiome(world, previousLevel, newLevel, biome);
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);
}
void setMobSpawning(UUID ownerId, boolean enabled) {
ensureWorld(ownerId).setSpawnFlags(enabled, enabled);
}
void setBiome(UUID ownerId, int level, PocketBaseBiome biome) {
World world = ensureWorld(ownerId);
applyBiome(world, 0, level, biome);
world.save();
}
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) {
World world = ensureWorld(ownerId);
if (!portal.worldId().equals(world.getUID())) {
return false;
}
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);
boolean created = world == null;
if (created) {
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);
}
if (created) {
world.setSpawnFlags(false, false);
}
ownersByWorld.put(world.getUID(), ownerId);
return world;
}
private void applyBiome(
World world,
int previousLevel,
int newLevel,
PocketBaseBiome selectedBiome
) {
int newHalfSize = policy.size(newLevel) / 2;
int previousHalfSize = previousLevel == 0 ? 0 : policy.size(previousLevel) / 2;
Biome biome = biomeResolver.apply(selectedBiome);
for (int x = -newHalfSize; x < newHalfSize; x += 4) {
for (int z = -newHalfSize; z < newHalfSize; z += 4) {
boolean previouslyUnlocked = previousLevel > 0
&& x >= -previousHalfSize && x < previousHalfSize
&& z >= -previousHalfSize && z < previousHalfSize;
if (previouslyUnlocked) {
continue;
}
for (int y = world.getMinHeight(); y < world.getMaxHeight(); y += 4) {
world.setBiome(x, y, z, biome);
}
}
}
}
private static Biome resolveBiome(Server server, PocketBaseBiome selectedBiome) {
Registry<Biome> biomeRegistry = server.getRegistry(Biome.class);
if (biomeRegistry == null) {
throw new IllegalStateException("Biome registry is unavailable");
}
return biomeRegistry.getOrThrow(
NamespacedKey.minecraft(selectedBiome.commandName())
);
}
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,22 @@
package games.dmg.spigotbase;
import java.util.Locale;
import java.util.Optional;
public enum PocketBaseWorldType {
VOID,
NETHER,
OVERWORLD;
public String commandName() {
return name().toLowerCase(Locale.ROOT);
}
static Optional<PocketBaseWorldType> fromCommand(String value) {
try {
return Optional.of(valueOf(value.toUpperCase(Locale.ROOT)));
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
}
@@ -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,141 @@
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"),
loadPortal(yaml, path + ".return-portal"),
yaml.getBoolean(path + ".mob-spawning-enabled", false),
loadBiome(yaml, path)
);
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());
yaml.set(path + ".mob-spawning-enabled", state.mobSpawningEnabled());
yaml.set(path + ".world-type", state.biome().worldType().commandName());
yaml.set(path + ".biome", state.biome().commandName());
state.entrance().ifPresent(portal -> savePortal(yaml, path + ".entrance", portal));
state.returnPortal().ifPresent(portal ->
savePortal(yaml, path + ".return-portal", 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 PocketBaseBiome loadBiome(YamlConfiguration yaml, String path) {
String worldTypeName = yaml.getString(path + ".world-type");
String biomeName = yaml.getString(path + ".biome");
if (worldTypeName == null || biomeName == null) {
return PocketBaseBiome.THE_VOID;
}
return PocketBaseWorldType.fromCommand(worldTypeName)
.flatMap(worldType -> PocketBaseBiome.fromCommand(worldType, biomeName))
.orElse(PocketBaseBiome.THE_VOID);
}
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 portal");
}
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());
}
}
+8
View File
@@ -46,6 +46,14 @@ 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-biome-change-cost: 16
pocket-base-biome-currency-material: NETHERITE_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.
@@ -6,10 +6,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
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 +24,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 +92,39 @@ 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"})
);
assertEquals(
List.of("mobs"),
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "m"})
);
assertEquals(
List.of("type"),
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "t"})
);
assertEquals(
List.of("nether"),
command.onTabComplete(
null, null, "basesettings", new String[] {"pocket", "type", "n"}
)
);
assertEquals(
List.of("crimson_forest"),
command.onTabComplete(
null,
null,
"basesettings",
new String[] {"pocket", "type", "nether", "c"}
)
);
assertEquals(
List.of("enable"),
command.onTabComplete(
null, null, "basesettings", new String[] {"pocket", "mobs", "e"}
)
);
for (String setting : List.of("navigation", "flight", "border", "spawnable", "bossbar")) {
assertEquals(
List.of("disable"),
@@ -107,7 +143,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 +191,272 @@ 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 pocketOwnerCanPurchaseBiomeChangeWithConfiguredCurrency() 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.NETHERITE_BLOCK, 16)
});
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState current = new PocketBaseState(playerId, 1, java.util.Optional.empty());
when(pocketBases.state(playerId)).thenReturn(current);
when(pocketBases.setBiome(playerId, PocketBaseBiome.CRIMSON_FOREST))
.thenReturn(current.withBiome(PocketBaseBiome.CRIMSON_FOREST));
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(player, null, "basesettings", new String[] {
"pocket", "type", "nether", "crimson_forest"
});
verify(pocketBases).setBiome(playerId, PocketBaseBiome.CRIMSON_FOREST);
verify(inventory).setStorageContents(any(ItemStack[].class));
}
@Test
void selectingCurrentPocketBiomeDoesNotCharge() 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);
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(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", "type", "void", "the_void"
});
verify(pocketBases, never()).setBiome(
org.mockito.ArgumentMatchers.eq(playerId),
org.mockito.ArgumentMatchers.any(PocketBaseBiome.class)
);
verify(inventory, never()).setStorageContents(any(ItemStack[].class));
}
@Test
void pocketOwnerCanEnableNaturalMobSpawning() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 1, java.util.Optional.empty(), false)
);
when(pocketBases.setMobSpawning(playerId, true)).thenReturn(
new PocketBaseState(playerId, 1, java.util.Optional.empty(), true)
);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(
player, null, "basesettings", new String[] {"pocket", "mobs", "enable"}
);
verify(pocketBases).setMobSpawning(playerId, true);
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("mob spawning") && message.contains("enabled")
));
}
@Test
void failedPocketMobSpawningChangeReportsOnlyFailure() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 1, java.util.Optional.empty(), false)
);
when(pocketBases.setMobSpawning(playerId, true))
.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", "mobs", "enable"}
);
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("could not be changed")
));
verify(player, never()).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("is now")
));
}
@Test
void lockedPlayerCannotEnablePocketMobSpawning() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(
player, null, "basesettings", new String[] {"pocket", "mobs", "enable"}
);
verify(pocketBases, never()).setMobSpawning(playerId, true);
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("Pocket Base I") && message.contains("locked")
));
}
@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 statusDisplaysPocketMobSpawningPreference() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 1, java.util.Optional.empty(), false)
);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(player, null, "basesettings", new String[] {"status"});
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("Pocket Base 1")
&& message.contains("type=void/the_void")
&& message.contains("mob spawning=disabled")
));
}
@Test
void bossbarEnableIsIdempotent() {
UUID playerId = UUID.randomUUID();
@@ -21,6 +21,31 @@ 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());
assertEquals(16, settings.pocketBaseBiomeChangeCost());
assertEquals("NETHERITE_BLOCK", settings.pocketBaseBiomeCurrencyMaterial());
}
@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",
"pocket-base-biome-change-cost", 12,
"pocket-base-biome-currency-material", "NETHERITE_INGOT"
));
assertEquals(32, settings.pocketBaseUnlockCost());
assertEquals(8, settings.pocketBaseUpgradeCost());
assertEquals("EMERALD_BLOCK", settings.pocketBaseCurrencyMaterial());
assertEquals("GOLD_BLOCK", settings.pocketBasePortalFrameMaterial());
assertEquals(12, settings.pocketBaseBiomeChangeCost());
assertEquals("NETHERITE_INGOT", settings.pocketBaseBiomeCurrencyMaterial());
}
@Test
@@ -0,0 +1,245 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.anyInt;
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.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 ownerCanActivateCompleteReturnFrameInsideOwnPocketBase() throws Exception {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PocketPortalLocation portal = new PocketPortalLocation(
worldId, "pocket", 8, 65, 8, PocketPortalAxis.X
);
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
Player owner = mock(Player.class);
World world = mock(World.class);
Block clicked = block(world, 8, 67, 8, Material.DIAMOND_BLOCK);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of());
when(owner.getUniqueId()).thenReturn(ownerId);
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("pocket");
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(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.onActivate(event);
verify(pocketBases).activateReturnPortal(ownerId, portal);
verify(event).setCancelled(true);
}
@Test
void returnFrameOutsideUnlockedPocketBoundaryIsRejected() throws Exception {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PocketPortalLocation portal = new PocketPortalLocation(
worldId, "pocket", 30, 65, 8, PocketPortalAxis.X
);
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
Player owner = mock(Player.class);
World world = mock(World.class);
Block clicked = block(world, 30, 67, 8, Material.DIAMOND_BLOCK);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of());
when(owner.getUniqueId()).thenReturn(ownerId);
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("pocket");
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(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.onActivate(event);
verify(pocketBases, never()).activateReturnPortal(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,159 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.UnaryOperator;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
final class PocketBaseManagerTest {
@Test
void relocatedReturnPortalOverridesGeneratedDefault() {
UUID ownerId = UUID.randomUUID();
PocketPortalLocation relocated = new PocketPortalLocation(
UUID.randomUUID(), "pocket", 20, 65, 12, PocketPortalAxis.Z
);
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
when(states.state(ownerId)).thenReturn(new PocketBaseState(
ownerId, 1, Optional.empty(), Optional.of(relocated), false
));
PocketBaseManager manager = new PocketBaseManager(
states,
worlds,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(relocated, manager.returnPortal(ownerId));
}
@Test
void existingStateWithoutRelocationUsesGeneratedReturnPortal() {
UUID ownerId = UUID.randomUUID();
PocketPortalLocation generated = new PocketPortalLocation(
UUID.randomUUID(), "pocket", -2, 65, 0, PocketPortalAxis.X
);
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
when(states.state(ownerId)).thenReturn(
new PocketBaseState(ownerId, 1, Optional.empty())
);
when(worlds.returnPortal(ownerId)).thenReturn(generated);
PocketBaseManager manager = new PocketBaseManager(
states,
worlds,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(generated, manager.returnPortal(ownerId));
}
@Test
void persistsAndAppliesMobSpawningPreference() throws Exception {
UUID ownerId = UUID.randomUUID();
PocketBaseState current = new PocketBaseState(ownerId, 1, Optional.empty(), false);
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
when(states.state(ownerId)).thenReturn(current);
when(states.updateAndSave(org.mockito.ArgumentMatchers.eq(ownerId),
org.mockito.ArgumentMatchers.any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PocketBaseState> operation = invocation.getArgument(1);
return operation.apply(current);
});
PocketBaseManager manager = new PocketBaseManager(
states,
worlds,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
manager.setMobSpawning(ownerId, true);
InOrder order = inOrder(worlds, states);
order.verify(worlds).setMobSpawning(ownerId, true);
order.verify(states).updateAndSave(
org.mockito.ArgumentMatchers.eq(ownerId), org.mockito.ArgumentMatchers.any()
);
}
@Test
void persistsBiomeAfterApplyingItToTheUnlockedArea() throws Exception {
UUID ownerId = UUID.randomUUID();
PocketBaseState current = new PocketBaseState(ownerId, 2, Optional.empty());
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
when(states.state(ownerId)).thenReturn(current);
when(states.updateAndSave(org.mockito.ArgumentMatchers.eq(ownerId),
org.mockito.ArgumentMatchers.any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PocketBaseState> operation = invocation.getArgument(1);
return operation.apply(current);
});
PocketBaseManager manager = new PocketBaseManager(
states,
worlds,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
PocketBaseState updated = manager.setBiome(ownerId, PocketBaseBiome.WARPED_FOREST);
assertEquals(PocketBaseBiome.WARPED_FOREST, updated.biome());
InOrder order = inOrder(worlds, states);
order.verify(worlds).setBiome(ownerId, 2, PocketBaseBiome.WARPED_FOREST);
order.verify(states).updateAndSave(
org.mockito.ArgumentMatchers.eq(ownerId), org.mockito.ArgumentMatchers.any()
);
}
@Test
void restoresPreviousBiomeWhenPersistenceFails() throws Exception {
UUID ownerId = UUID.randomUUID();
PocketBaseState current = new PocketBaseState(ownerId, 1, Optional.empty());
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
when(states.state(ownerId)).thenReturn(current);
when(states.updateAndSave(org.mockito.ArgumentMatchers.eq(ownerId),
org.mockito.ArgumentMatchers.any())).thenThrow(new IOException("save failed"));
PocketBaseManager manager = new PocketBaseManager(
states,
worlds,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertThrows(IOException.class,
() -> manager.setBiome(ownerId, PocketBaseBiome.DESERT));
verify(worlds).setBiome(ownerId, 1, PocketBaseBiome.DESERT);
verify(worlds).setBiome(ownerId, 1, PocketBaseBiome.THE_VOID);
}
@Test
void restoresWorldSettingWhenPersistenceFails() throws Exception {
UUID ownerId = UUID.randomUUID();
PocketBaseState current = new PocketBaseState(ownerId, 1, Optional.empty(), false);
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
when(states.state(ownerId)).thenReturn(current);
when(states.updateAndSave(org.mockito.ArgumentMatchers.eq(ownerId),
org.mockito.ArgumentMatchers.any())).thenThrow(new IOException("save failed"));
PocketBaseManager manager = new PocketBaseManager(
states,
worlds,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertThrows(IOException.class, () -> manager.setMobSpawning(ownerId, true));
verify(worlds).setMobSpawning(ownerId, true);
verify(worlds).setMobSpawning(ownerId, false);
}
}
@@ -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,170 @@
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 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.Optional;
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");
when(world.getMinHeight()).thenReturn(0);
when(world.getMaxHeight()).thenReturn(8);
PocketBaseWorldService service = new PocketBaseWorldService(
server,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
ignored -> null
);
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);
verify(world).setSpawnFlags(false, false);
assertEquals(ownerId, service.ownerForWorld(worldId).orElseThrow());
}
@Test
void checksRelocatedReturnPortalFrameAtItsSavedLocation() {
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.getWorld(org.mockito.ArgumentMatchers.anyString())).thenReturn(world);
when(world.getUID()).thenReturn(worldId);
PocketPortalLocation portal = new PocketPortalLocation(
worldId, "pocket", 20, 65, 12, PocketPortalAxis.Z
);
for (BlockPosition position : PocketPortalGeometry.frameBlocks(portal)) {
when(world.getBlockAt(position.x(), position.y(), position.z()).getType())
.thenReturn(Material.DIAMOND_BLOCK);
}
PocketBaseWorldService service = new PocketBaseWorldService(
server,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertTrue(service.returnPortalIsIntact(ownerId, portal));
when(world.getBlockAt(20, 65, 12).getType()).thenReturn(Material.AIR);
assertFalse(service.returnPortalIsIntact(ownerId, portal));
}
@Test
void reappliesSavedMobSpawningPreferenceWhenWorldLoads() {
UUID ownerId = UUID.randomUUID();
Server server = mock(Server.class);
World world = mock(World.class);
when(server.getWorld(org.mockito.ArgumentMatchers.anyString())).thenReturn(world);
when(world.getUID()).thenReturn(UUID.randomUUID());
PocketBaseWorldService service = new PocketBaseWorldService(
server,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
ignored -> null
);
service.loadExisting(Map.of(
ownerId,
new PocketBaseState(ownerId, 1, Optional.empty(), true)
));
verify(world).setSpawnFlags(true, true);
}
@Test
void appliesSelectedBiomeAcrossUnlockedColumnsWithoutChangingBlocks() {
UUID ownerId = UUID.randomUUID();
Server server = mock(Server.class);
World world = mock(World.class);
when(server.getWorld(org.mockito.ArgumentMatchers.anyString())).thenReturn(world);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.getMinHeight()).thenReturn(0);
when(world.getMaxHeight()).thenReturn(8);
PocketBaseWorldService service = new PocketBaseWorldService(
server,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
ignored -> null
);
service.setBiome(ownerId, 1, PocketBaseBiome.CRIMSON_FOREST);
verify(world).setBiome(-32, 0, -32, null);
verify(world).setBiome(28, 4, 28, null);
verify(world, never()).getBlockAt(
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.anyInt()
);
}
@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");
when(world.getMinHeight()).thenReturn(0);
when(world.getMaxHeight()).thenReturn(8);
PocketBaseWorldService service = new PocketBaseWorldService(
server,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
ignored -> null
);
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());
verify(world).setBiome(44, 4, 44, null);
verify(world, never()).setBiome(0, 0, 0, null);
}
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,57 @@
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.nio.file.Files;
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
)),
Optional.of(new PocketPortalLocation(
worldId, "pocket", 20, 65, 12, PocketPortalAxis.Z
)),
true,
PocketBaseBiome.CRIMSON_FOREST
);
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(
temporaryDirectory.resolve("pocket-bases.yml")
);
repository.save(Map.of(ownerId, expected));
assertEquals(expected, repository.load().get(ownerId));
}
@Test
void existingRecordWithoutMobPreferenceDefaultsToDisabled() throws Exception {
UUID ownerId = UUID.randomUUID();
Path stateFile = temporaryDirectory.resolve("pocket-bases.yml");
Files.writeString(stateFile, "owners:\n " + ownerId + ":\n level: 1\n");
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(stateFile);
PocketBaseState loaded = repository.load().get(ownerId);
assertFalse(loaded.mobSpawningEnabled());
assertTrue(loaded.returnPortal().isEmpty());
assertEquals(PocketBaseBiome.THE_VOID, loaded.biome());
}
}