4 Commits
Author SHA1 Message Date
dmg b2ba4d5821 feat(pocket-base): split hostile and passive mob spawning
Release / release (push) Successful in 2m50s
CI / build (push) Successful in 1m13s
2026-08-24 10:41:44 -04:00
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
23 changed files with 1368 additions and 33 deletions
+5 -1
View File
@@ -29,6 +29,8 @@ The plugin JAR is written to `build/libs/`.
/basesettings status /basesettings status
/basesettings upgrade /basesettings upgrade
/basesettings pocket upgrade /basesettings pocket upgrade
/basesettings pocket mobs <hostile|passive> <enable|disable>
/basesettings pocket type <void|nether|overworld> <subtype>
/basesettings visitors <allowed|blocked> /basesettings visitors <allowed|blocked>
/basesettings navigation <enable|disable> /basesettings navigation <enable|disable>
/basesettings flight <enable|disable> /basesettings flight <enable|disable>
@@ -43,7 +45,9 @@ 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` 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. 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. Owners can control each category independently with `/basesettings pocket mobs <hostile|passive> <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 ## Administration
+23
View File
@@ -91,3 +91,26 @@ 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. - Used the live size-tier radius so expansion and relocation immediately update the visibility threshold.
- Added exact-boundary and controller integration coverage. - Added exact-boundary and controller integration coverage.
- Verified the change with `./gradlew clean check jar`. - 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 independent persisted owner settings through `/basesettings pocket mobs <hostile|passive> <enable|disable>`, contextual autocomplete, and status reporting.
- Migrated each legacy combined mob-spawning preference to both categories to preserve existing behavior.
- Reapplied both saved spawn flags when Pocket Base worlds load and restored both prior world settings 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`.
+2
View File
@@ -23,3 +23,5 @@ description: Catalog of user stories for the Spigot Base plugin.
15. [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.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) 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) 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)
@@ -21,7 +21,12 @@ As a **Pocket Base owner**, I want to connect my normal base to my Pocket Base w
- [x] Any player can use an active entrance without a separate visitor setting, warm-up, or cooldown. - [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] 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] The generated return portal sends players safely to the owner's active entrance.
- [x] Return travel falls back to the server spawn when the owner has no valid active entrance. - [x] 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] 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] 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] Moving the normal base deactivates its entrance while preserving the Pocket Base world and its contents.
@@ -0,0 +1,31 @@
---
type: User Story
title: "US-018: Control Pocket Base mob spawning"
description: Let Pocket Base owners control hostile and passive natural mob spawning independently in their private world, with both disabled by default.
status: done
---
# US-018: Control Pocket Base mob spawning
As a **Pocket Base owner**, I want to enable or disable hostile and passive natural mob spawning independently in my Pocket Base so that I can choose which kinds of normal mob activity its private world supports.
## Acceptance criteria
- [x] Hostile and passive natural mob spawning are both 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 hostile enable|disable` to control hostile natural mob spawning in their own Pocket Base.
- [x] A Pocket Base owner can use `/basesettings pocket mobs passive enable|disable` to control passive natural mob spawning in their own Pocket Base.
- [x] Changing one mob category does not change the other category.
- [x] The commands are unavailable until Pocket Base I is unlocked.
- [x] The commands, categories, and modes are offered through contextual autocomplete.
- [x] `/basesettings status` displays the current hostile and passive Pocket Base mob-spawning settings.
- [x] Both preferences persist across restarts and are reapplied when the Pocket Base world loads.
- [x] Existing saved `mob-spawning-enabled` values migrate to both category preferences, preserving prior behavior.
- [x] Disabling either natural-spawning category does not remove existing mobs or prevent explicitly spawned or summoned mobs.
- [x] Persistence or world-application failures produce a clear failure message, restore both prior world flags, and do not report 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)
@@ -20,6 +20,9 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
); );
private static final List<String> VISITOR_MODES = List.of("allowed", "blocked"); 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> 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 BaseStateManager stateManager;
private final PluginSettingsProvider settings; private final PluginSettingsProvider settings;
private final TeleportPolicy teleportPolicy; private final TeleportPolicy teleportPolicy;
@@ -66,6 +69,14 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
&& arguments[1].equalsIgnoreCase("upgrade")) { && arguments[1].equalsIgnoreCase("upgrade")) {
return purchasePocketUpgrade(player, state); return purchasePocketUpgrade(player, state);
} }
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("pocket")
&& arguments[1].equalsIgnoreCase("mobs")) {
return updatePocketMobSpawning(player, arguments[2], arguments[3]);
}
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")) { if (arguments.length == 2 && arguments[0].equalsIgnoreCase("visitors")) {
return updateVisitors(player, state, arguments[1]); return updateVisitors(player, state, arguments[1]);
} }
@@ -176,6 +187,107 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
return true; 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 category,
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;
final boolean settingEnabled;
try {
if (category.equalsIgnoreCase("hostile")) {
updated = pocketBases.setHostileMobSpawning(
player.getUniqueId(), enabled
);
settingEnabled = updated.hostileMobSpawningEnabled();
} else if (category.equalsIgnoreCase("passive")) {
updated = pocketBases.setPassiveMobSpawning(
player.getUniqueId(), enabled
);
settingEnabled = updated.passiveMobSpawningEnabled();
} else {
sendUsage(player);
return true;
}
} 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 "
+ category.toLowerCase(Locale.ROOT) + " mob spawning is now "
+ (settingEnabled ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
return true;
}
private boolean updateVisitors(Player player, PlayerState state, String mode) { private boolean updateVisitors(Player player, PlayerState state, String mode) {
if (state.baseLevel() < 4) { if (state.baseLevel() < 4) {
player.sendMessage(ChatColor.RED + "Base IV visitor access is still locked."); player.sendMessage(ChatColor.RED + "Base IV visitor access is still locked.");
@@ -408,14 +520,21 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
} }
private void showPocketPath(Player player, PlayerState owner) { private void showPocketPath(Player player, PlayerState owner) {
int level = pocketBases == null ? 0 : pocketBases.state(owner.playerId()).level(); PocketBaseState pocket = pocketBases == null
int price = level == 0 ? PocketBaseState.locked(owner.playerId())
: pocketBases.state(owner.playerId());
int price = pocket.level() == 0
? settings.current().pocketBaseUnlockCost() ? settings.current().pocketBaseUnlockCost()
: settings.current().pocketBaseUpgradeCost(); : settings.current().pocketBaseUpgradeCost();
player.sendMessage(colorForPrerequisite(owner.baseLevel() >= 4 && owner.base().isPresent()) player.sendMessage(colorForPrerequisite(owner.baseLevel() >= 4 && owner.base().isPresent())
+ "Pocket Base " + level + ": " + ChatColor.GRAY + price + " " + "Pocket Base " + pocket.level() + ": " + ChatColor.GRAY + price + " "
+ settings.current().pocketBaseCurrencyMaterial().toLowerCase(Locale.ROOT) + settings.current().pocketBaseCurrencyMaterial().toLowerCase(Locale.ROOT)
+ " → /basesettings pocket upgrade"); + " → /basesettings pocket upgrade; type="
+ pocket.biome().worldType().commandName() + "/" + pocket.biome().commandName()
+ "; hostile spawning="
+ (pocket.hostileMobSpawningEnabled() ? "enabled" : "disabled")
+ "; passive spawning="
+ (pocket.passiveMobSpawningEnabled() ? "enabled" : "disabled"));
} }
private void showCooldownPath(Player player, PlayerState state) { private void showCooldownPath(Player player, PlayerState state) {
@@ -447,13 +566,44 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
List<String> modes = arguments[0].equalsIgnoreCase("visitors") List<String> modes = arguments[0].equalsIgnoreCase("visitors")
? VISITOR_MODES ? VISITOR_MODES
: switch (arguments[0].toLowerCase(Locale.ROOT)) { : switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "pocket" -> List.of("upgrade"); case "pocket" -> List.of("upgrade", "mobs", "type");
case "navigation", "flight", "border", "spawnable", "bossbar" -> ENABLE_MODES; case "navigation", "flight", "border", "spawnable", "bossbar" -> ENABLE_MODES;
default -> List.of(); default -> List.of();
}; };
String prefix = arguments[1].toLowerCase(Locale.ROOT); String prefix = arguments[1].toLowerCase(Locale.ROOT);
return modes.stream().filter(mode -> mode.startsWith(prefix)).toList(); 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 List.of("hostile", "passive").stream()
.filter(category -> category.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")) {
String prefix = arguments[3].toLowerCase(Locale.ROOT);
if (arguments[1].equalsIgnoreCase("mobs")
&& (arguments[2].equalsIgnoreCase("hostile")
|| arguments[2].equalsIgnoreCase("passive"))) {
return ENABLE_MODES.stream()
.filter(mode -> mode.startsWith(prefix))
.toList();
}
if (arguments[1].equalsIgnoreCase("type")) {
return PocketBaseWorldType.fromCommand(arguments[2])
.map(PocketBaseBiome::commandNames)
.orElse(List.of())
.stream()
.filter(biome -> biome.startsWith(prefix))
.toList();
}
}
return List.of(); return List.of();
} }
@@ -513,8 +663,10 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private static void sendUsage(Player player) { private static void sendUsage(Player player) {
player.sendMessage(ChatColor.RED + "Usage: /basesettings " player.sendMessage(ChatColor.RED + "Usage: /basesettings "
+ "[status|upgrade|pocket upgrade|visitors <allowed|blocked>" + "[status|upgrade|pocket upgrade|pocket mobs "
+ "|navigation <enable|disable>" + "<hostile|passive> <enable|disable>"
+ "|pocket type <void|nether|overworld> <subtype>"
+ "|visitors <allowed|blocked>|navigation <enable|disable>"
+ "|flight <enable|disable>|border <enable|disable>" + "|flight <enable|disable>|border <enable|disable>"
+ "|spawnable <enable|disable>|bossbar <enable|disable>]"); + "|spawnable <enable|disable>|bossbar <enable|disable>]");
} }
@@ -39,12 +39,14 @@ public record PluginSettings(
int visitorUnlockDiamondCost, int visitorUnlockDiamondCost,
int pocketBaseUnlockCost, int pocketBaseUnlockCost,
int pocketBaseUpgradeCost, int pocketBaseUpgradeCost,
int pocketBaseBiomeChangeCost,
Set<String> baseUnlockMaterials, Set<String> baseUnlockMaterials,
String stoneExpansionMaterial, String stoneExpansionMaterial,
String deepslateExpansionMaterial, String deepslateExpansionMaterial,
String obsidianExpansionMaterial, String obsidianExpansionMaterial,
String visitorCurrencyMaterial, String visitorCurrencyMaterial,
String pocketBaseCurrencyMaterial, String pocketBaseCurrencyMaterial,
String pocketBaseBiomeCurrencyMaterial,
String pocketBasePortalFrameMaterial, String pocketBasePortalFrameMaterial,
Set<String> cooldownExcludedMaterials, Set<String> cooldownExcludedMaterials,
int bossBarDurationTicks, int bossBarDurationTicks,
@@ -85,6 +87,7 @@ public record PluginSettings(
private static final int DEFAULT_VISITOR_UNLOCK_DIAMOND_COST = 128; 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_UNLOCK_COST = 64;
private static final int DEFAULT_POCKET_BASE_UPGRADE_COST = 16; 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_BOSS_BAR_DURATION_TICKS = 60;
private static final int DEFAULT_NAVIGATION_PARTICLE_COUNT = 5; private static final int DEFAULT_NAVIGATION_PARTICLE_COUNT = 5;
private static final int DEFAULT_TITLE_FADE_IN_TICKS = 10; private static final int DEFAULT_TITLE_FADE_IN_TICKS = 10;
@@ -148,6 +151,7 @@ public record PluginSettings(
requirePositive(visitorUnlockDiamondCost, "visitor-unlock-diamond-cost"); requirePositive(visitorUnlockDiamondCost, "visitor-unlock-diamond-cost");
requirePositive(pocketBaseUnlockCost, "pocket-base-unlock-cost"); requirePositive(pocketBaseUnlockCost, "pocket-base-unlock-cost");
requirePositive(pocketBaseUpgradeCost, "pocket-base-upgrade-cost"); requirePositive(pocketBaseUpgradeCost, "pocket-base-upgrade-cost");
requirePositive(pocketBaseBiomeChangeCost, "pocket-base-biome-change-cost");
baseUnlockMaterials = normalizedSet(baseUnlockMaterials, "base-unlock-materials", false); baseUnlockMaterials = normalizedSet(baseUnlockMaterials, "base-unlock-materials", false);
stoneExpansionMaterial = normalizedName(stoneExpansionMaterial, "stone-expansion-material"); stoneExpansionMaterial = normalizedName(stoneExpansionMaterial, "stone-expansion-material");
deepslateExpansionMaterial = normalizedName( deepslateExpansionMaterial = normalizedName(
@@ -162,6 +166,9 @@ public record PluginSettings(
pocketBaseCurrencyMaterial = normalizedName( pocketBaseCurrencyMaterial = normalizedName(
pocketBaseCurrencyMaterial, "pocket-base-currency-material" pocketBaseCurrencyMaterial, "pocket-base-currency-material"
); );
pocketBaseBiomeCurrencyMaterial = normalizedName(
pocketBaseBiomeCurrencyMaterial, "pocket-base-biome-currency-material"
);
pocketBasePortalFrameMaterial = normalizedName( pocketBasePortalFrameMaterial = normalizedName(
pocketBasePortalFrameMaterial, "pocket-base-portal-frame-material" pocketBasePortalFrameMaterial, "pocket-base-portal-frame-material"
); );
@@ -230,12 +237,18 @@ public record PluginSettings(
integer(values, "visitor-unlock-diamond-cost", DEFAULT_VISITOR_UNLOCK_DIAMOND_COST), 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-unlock-cost", DEFAULT_POCKET_BASE_UNLOCK_COST),
integer(values, "pocket-base-upgrade-cost", DEFAULT_POCKET_BASE_UPGRADE_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")), stringSet(values, "base-unlock-materials", Set.of("GRASS_BLOCK", "DIRT")),
string(values, "stone-expansion-material", "STONE"), string(values, "stone-expansion-material", "STONE"),
string(values, "deepslate-expansion-material", "DEEPSLATE"), string(values, "deepslate-expansion-material", "DEEPSLATE"),
string(values, "obsidian-expansion-material", "OBSIDIAN"), string(values, "obsidian-expansion-material", "OBSIDIAN"),
string(values, "visitor-currency-material", "DIAMOND"), string(values, "visitor-currency-material", "DIAMOND"),
string(values, "pocket-base-currency-material", "DIAMOND_BLOCK"), 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"), string(values, "pocket-base-portal-frame-material", "DIAMOND_BLOCK"),
stringSet(values, "cooldown-excluded-materials", Set.of()), stringSet(values, "cooldown-excluded-materials", Set.of()),
integer(values, "boss-bar-duration-ticks", DEFAULT_BOSS_BAR_DURATION_TICKS), integer(values, "boss-bar-duration-ticks", DEFAULT_BOSS_BAR_DURATION_TICKS),
@@ -29,6 +29,10 @@ final class PluginSettingsValidator {
} }
validateCurrency(settings.visitorCurrencyMaterial(), "visitor-currency-material"); validateCurrency(settings.visitorCurrencyMaterial(), "visitor-currency-material");
validateCurrency(settings.pocketBaseCurrencyMaterial(), "pocket-base-currency-material"); validateCurrency(settings.pocketBaseCurrencyMaterial(), "pocket-base-currency-material");
validateCurrency(
settings.pocketBaseBiomeCurrencyMaterial(),
"pocket-base-biome-currency-material"
);
return settings; return settings;
} }
@@ -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();
}
}
@@ -79,10 +79,6 @@ final class PocketBaseController implements Listener, Runnable {
if (pocket.level() < 1) { if (pocket.level() < 1) {
return; return;
} }
PlayerState owner = baseStates.player(player.getUniqueId(), player.getName());
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
return;
}
Block clicked = event.getClickedBlock(); Block clicked = event.getClickedBlock();
World world = clicked.getWorld(); World world = clicked.getWorld();
Material frameMaterial = Material.valueOf( Material frameMaterial = Material.valueOf(
@@ -98,6 +94,37 @@ final class PocketBaseController implements Listener, Runnable {
if (portal.isEmpty()) { if (portal.isEmpty()) {
return; 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); BaseArea area = baseBounds.area(owner);
if (PocketPortalGeometry.frameBlocks(portal.orElseThrow()).stream().anyMatch(candidate -> if (PocketPortalGeometry.frameBlocks(portal.orElseThrow()).stream().anyMatch(candidate ->
!area.contains(world.getUID(), candidate.x(), candidate.y(), candidate.z()))) { !area.contains(world.getUID(), candidate.x(), candidate.y(), candidate.z()))) {
@@ -38,7 +38,7 @@ final class PocketBaseManager {
PocketBaseState current = states.state(ownerId); PocketBaseState current = states.state(ownerId);
int nextLevel = Math.addExact(current.level(), 1); int nextLevel = Math.addExact(current.level(), 1);
policy.size(nextLevel); policy.size(nextLevel);
worlds.expand(ownerId, current.level(), nextLevel); worlds.expand(ownerId, current.level(), nextLevel, current.biome());
return states.updateAndSave(ownerId, state -> state.withLevel(nextLevel)); return states.updateAndSave(ownerId, state -> state.withLevel(nextLevel));
} }
@@ -60,6 +60,87 @@ final class PocketBaseManager {
} }
} }
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 setHostileMobSpawning(UUID ownerId, boolean enabled)
throws IOException {
PocketBaseState current = states.state(ownerId);
return setMobSpawning(
current,
enabled,
current.passiveMobSpawningEnabled(),
state -> state.withHostileMobSpawningEnabled(enabled)
);
}
PocketBaseState setPassiveMobSpawning(UUID ownerId, boolean enabled)
throws IOException {
PocketBaseState current = states.state(ownerId);
return setMobSpawning(
current,
current.hostileMobSpawningEnabled(),
enabled,
state -> state.withPassiveMobSpawningEnabled(enabled)
);
}
private PocketBaseState setMobSpawning(
PocketBaseState current,
boolean hostileEnabled,
boolean passiveEnabled,
java.util.function.UnaryOperator<PocketBaseState> update
) throws IOException {
if (current.level() < 1) {
throw new IllegalStateException("Pocket Base I is still locked");
}
UUID ownerId = current.ownerId();
worlds.setMobSpawning(ownerId, hostileEnabled, passiveEnabled);
try {
return states.updateAndSave(ownerId, update);
} catch (IOException | RuntimeException exception) {
try {
worlds.setMobSpawning(
ownerId,
current.hostileMobSpawningEnabled(),
current.passiveMobSpawningEnabled()
);
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
throw exception;
}
}
Optional<UUID> ownerForPocketWorld(UUID worldId) { Optional<UUID> ownerForPocketWorld(UUID worldId) {
return worlds.ownerForWorld(worldId); return worlds.ownerForWorld(worldId);
} }
@@ -69,11 +150,13 @@ final class PocketBaseManager {
} }
PocketPortalLocation returnPortal(UUID ownerId) { PocketPortalLocation returnPortal(UUID ownerId) {
return worlds.returnPortal(ownerId); return states.state(ownerId).returnPortal().orElseGet(
() -> worlds.returnPortal(ownerId)
);
} }
boolean returnPortalIsIntact(UUID ownerId) { boolean returnPortalIsIntact(UUID ownerId) {
return worlds.returnPortalIsIntact(ownerId); return worlds.returnPortalIsIntact(ownerId, returnPortal(ownerId));
} }
World pocketWorld(UUID ownerId) { World pocketWorld(UUID ownerId) {
@@ -6,7 +6,11 @@ import java.util.UUID;
public record PocketBaseState( public record PocketBaseState(
UUID ownerId, UUID ownerId,
int level, int level,
Optional<PocketPortalLocation> entrance Optional<PocketPortalLocation> entrance,
Optional<PocketPortalLocation> returnPortal,
boolean hostileMobSpawningEnabled,
boolean passiveMobSpawningEnabled,
PocketBaseBiome biome
) { ) {
public PocketBaseState { public PocketBaseState {
if (ownerId == null) { if (ownerId == null) {
@@ -16,24 +20,77 @@ public record PocketBaseState(
throw new IllegalArgumentException("Pocket Base level must not be negative"); throw new IllegalArgumentException("Pocket Base level must not be negative");
} }
entrance = entrance == null ? Optional.empty() : entrance; entrance = entrance == null ? Optional.empty() : entrance;
if (level == 0 && entrance.isPresent()) { returnPortal = returnPortal == null ? Optional.empty() : returnPortal;
throw new IllegalArgumentException("a locked Pocket Base cannot have an entrance"); 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, false,
PocketBaseBiome.THE_VOID
);
}
public static PocketBaseState locked(UUID ownerId) { public static PocketBaseState locked(UUID ownerId) {
return new PocketBaseState(ownerId, 0, Optional.empty()); return new PocketBaseState(
ownerId, 0, Optional.empty(), Optional.empty(), false, false,
PocketBaseBiome.THE_VOID
);
} }
public PocketBaseState withLevel(int newLevel) { public PocketBaseState withLevel(int newLevel) {
return new PocketBaseState(ownerId, newLevel, entrance); return new PocketBaseState(
ownerId, newLevel, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome
);
} }
public PocketBaseState withEntrance(PocketPortalLocation portal) { public PocketBaseState withEntrance(PocketPortalLocation portal) {
return new PocketBaseState(ownerId, level, Optional.of(portal)); return new PocketBaseState(
ownerId, level, Optional.of(portal), returnPortal,
hostileMobSpawningEnabled, passiveMobSpawningEnabled, biome
);
} }
public PocketBaseState withoutEntrance() { public PocketBaseState withoutEntrance() {
return new PocketBaseState(ownerId, level, Optional.empty()); return new PocketBaseState(
ownerId, level, Optional.empty(), returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome
);
}
public PocketBaseState withReturnPortal(PocketPortalLocation portal) {
return new PocketBaseState(
ownerId, level, entrance, Optional.of(portal), hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome
);
}
public PocketBaseState withHostileMobSpawningEnabled(boolean enabled) {
return new PocketBaseState(
ownerId, level, entrance, returnPortal, enabled,
passiveMobSpawningEnabled, biome
);
}
public PocketBaseState withPassiveMobSpawningEnabled(boolean enabled) {
return new PocketBaseState(
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
enabled, biome
);
}
public PocketBaseState withBiome(PocketBaseBiome selectedBiome) {
return new PocketBaseState(
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, selectedBiome
);
} }
} }
@@ -4,11 +4,15 @@ import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.function.Function;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Registry;
import org.bukkit.Server; import org.bukkit.Server;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.WorldCreator; import org.bukkit.WorldCreator;
import org.bukkit.block.Biome;
final class PocketBaseWorldService { final class PocketBaseWorldService {
private static final int PLATFORM_Y = 64; private static final int PLATFORM_Y = 64;
@@ -16,23 +20,47 @@ final class PocketBaseWorldService {
private final Server server; private final Server server;
private final PluginSettingsProvider settings; private final PluginSettingsProvider settings;
private final PocketBasePolicy policy; private final PocketBasePolicy policy;
private final Function<PocketBaseBiome, Biome> biomeResolver;
private final Map<UUID, UUID> ownersByWorld = new HashMap<>(); private final Map<UUID, UUID> ownersByWorld = new HashMap<>();
PocketBaseWorldService(Server server, PluginSettingsProvider settings) { 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.server = server;
this.settings = settings; this.settings = settings;
this.policy = new PocketBasePolicy(settings); this.policy = new PocketBasePolicy(settings);
this.biomeResolver = biomeResolver;
} }
void loadExisting(Map<UUID, PocketBaseState> states) { void loadExisting(Map<UUID, PocketBaseState> states) {
for (PocketBaseState state : states.values()) { for (PocketBaseState state : states.values()) {
if (state.level() > 0) { if (state.level() > 0) {
ensureWorld(state.ownerId()); setBiome(state.ownerId(), state.level(), state.biome());
setMobSpawning(
state.ownerId(),
state.hostileMobSpawningEnabled(),
state.passiveMobSpawningEnabled()
);
} }
} }
} }
World expand(UUID ownerId, int previousLevel, int newLevel) { 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); World world = ensureWorld(ownerId);
int newHalfSize = policy.size(newLevel) / 2; int newHalfSize = policy.size(newLevel) / 2;
int previousHalfSize = previousLevel == 0 ? 0 : policy.size(previousLevel) / 2; int previousHalfSize = previousLevel == 0 ? 0 : policy.size(previousLevel) / 2;
@@ -50,6 +78,7 @@ final class PocketBaseWorldService {
world.getBlockAt(x, PLATFORM_Y, z).setType(Material.GRASS_BLOCK, false); world.getBlockAt(x, PLATFORM_Y, z).setType(Material.GRASS_BLOCK, false);
} }
} }
applyBiome(world, previousLevel, newLevel, biome);
if (previousLevel == 0) { if (previousLevel == 0) {
createReturnPortal(world); createReturnPortal(world);
world.setSpawnLocation(0, PLATFORM_Y + 1, 5); world.setSpawnLocation(0, PLATFORM_Y + 1, 5);
@@ -66,6 +95,20 @@ final class PocketBaseWorldService {
return ensureWorld(ownerId); return ensureWorld(ownerId);
} }
void setMobSpawning(
UUID ownerId,
boolean hostileEnabled,
boolean passiveEnabled
) {
ensureWorld(ownerId).setSpawnFlags(hostileEnabled, passiveEnabled);
}
void setBiome(UUID ownerId, int level, PocketBaseBiome biome) {
World world = ensureWorld(ownerId);
applyBiome(world, 0, level, biome);
world.save();
}
Location arrival(UUID ownerId) { Location arrival(UUID ownerId) {
return new Location(ensureWorld(ownerId), 0.5, PLATFORM_Y + 1, 5.5, 180.0f, 0.0f); return new Location(ensureWorld(ownerId), 0.5, PLATFORM_Y + 1, 5.5, 180.0f, 0.0f);
} }
@@ -77,9 +120,11 @@ final class PocketBaseWorldService {
); );
} }
boolean returnPortalIsIntact(UUID ownerId) { boolean returnPortalIsIntact(UUID ownerId, PocketPortalLocation portal) {
PocketPortalLocation portal = returnPortal(ownerId);
World world = ensureWorld(ownerId); World world = ensureWorld(ownerId);
if (!portal.worldId().equals(world.getUID())) {
return false;
}
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial()); Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
return PocketPortalGeometry.frameBlocks(portal).stream() return PocketPortalGeometry.frameBlocks(portal).stream()
.allMatch(position -> world.getBlockAt( .allMatch(position -> world.getBlockAt(
@@ -90,7 +135,8 @@ final class PocketBaseWorldService {
private World ensureWorld(UUID ownerId) { private World ensureWorld(UUID ownerId) {
String name = worldName(ownerId); String name = worldName(ownerId);
World world = server.getWorld(name); World world = server.getWorld(name);
if (world == null) { boolean created = world == null;
if (created) {
WorldCreator creator = new WorldCreator(name) WorldCreator creator = new WorldCreator(name)
.environment(World.Environment.NORMAL) .environment(World.Environment.NORMAL)
.generator(new VoidPocketChunkGenerator()) .generator(new VoidPocketChunkGenerator())
@@ -100,10 +146,47 @@ final class PocketBaseWorldService {
if (world == null) { if (world == null) {
throw new IllegalStateException("Could not create Pocket Base world " + name); throw new IllegalStateException("Could not create Pocket Base world " + name);
} }
if (created) {
world.setSpawnFlags(false, false);
}
ownersByWorld.put(world.getUID(), ownerId); ownersByWorld.put(world.getUID(), ownerId);
return world; 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) { private void createReturnPortal(World world) {
PocketPortalLocation portal = new PocketPortalLocation( PocketPortalLocation portal = new PocketPortalLocation(
world.getUID(), world.getName(), -2, PLATFORM_Y + 1, 0, PocketPortalAxis.X world.getUID(), world.getName(), -2, PLATFORM_Y + 1, 0, PocketPortalAxis.X
@@ -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();
}
}
}
@@ -42,7 +42,11 @@ public final class YamlPocketBaseRepository {
PocketBaseState state = new PocketBaseState( PocketBaseState state = new PocketBaseState(
ownerId, ownerId,
level, level,
loadPortal(yaml, path + ".entrance") loadPortal(yaml, path + ".entrance"),
loadPortal(yaml, path + ".return-portal"),
loadMobPreference(yaml, path, "hostile-mob-spawning-enabled"),
loadMobPreference(yaml, path, "passive-mob-spawning-enabled"),
loadBiome(yaml, path)
); );
states.put(ownerId, state); states.put(ownerId, state);
} catch (IllegalArgumentException ignored) { } catch (IllegalArgumentException ignored) {
@@ -61,7 +65,20 @@ public final class YamlPocketBaseRepository {
for (PocketBaseState state : states.values()) { for (PocketBaseState state : states.values()) {
String path = "owners." + state.ownerId(); String path = "owners." + state.ownerId();
yaml.set(path + ".level", state.level()); yaml.set(path + ".level", state.level());
yaml.set(
path + ".hostile-mob-spawning-enabled",
state.hostileMobSpawningEnabled()
);
yaml.set(
path + ".passive-mob-spawning-enabled",
state.passiveMobSpawningEnabled()
);
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.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"); Path temporary = Files.createTempFile(parent, "spigot-base-pocket-", ".yml");
try { try {
@@ -81,6 +98,28 @@ public final class YamlPocketBaseRepository {
} }
} }
private static boolean loadMobPreference(
YamlConfiguration yaml,
String path,
String categoryKey
) {
return yaml.getBoolean(
path + "." + categoryKey,
yaml.getBoolean(path + ".mob-spawning-enabled", false)
);
}
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( private static Optional<PocketPortalLocation> loadPortal(
YamlConfiguration yaml, YamlConfiguration yaml,
String path String path
@@ -94,7 +133,7 @@ public final class YamlPocketBaseRepository {
if (worldId == null || worldName == null || axis == null if (worldId == null || worldName == null || axis == null
|| !yaml.isInt(path + ".x") || !yaml.isInt(path + ".y") || !yaml.isInt(path + ".x") || !yaml.isInt(path + ".y")
|| !yaml.isInt(path + ".z")) { || !yaml.isInt(path + ".z")) {
throw new IllegalArgumentException("invalid Pocket Base entrance"); throw new IllegalArgumentException("invalid Pocket Base portal");
} }
return Optional.of(new PocketPortalLocation( return Optional.of(new PocketPortalLocation(
UUID.fromString(worldId), UUID.fromString(worldId),
+2
View File
@@ -50,6 +50,8 @@ visitor-currency-material: DIAMOND
pocket-base-unlock-cost: 64 pocket-base-unlock-cost: 64
pocket-base-upgrade-cost: 16 pocket-base-upgrade-cost: 16
pocket-base-currency-material: DIAMOND_BLOCK 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 pocket-base-portal-frame-material: DIAMOND_BLOCK
# Spawnable block overlay # Spawnable block overlay
@@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -95,6 +96,48 @@ final class BaseSettingsCommandTest {
List.of("upgrade"), List.of("upgrade"),
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "u"}) 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("hostile"),
command.onTabComplete(
null, null, "basesettings", new String[] {"pocket", "mobs", "h"}
)
);
assertEquals(
List.of("passive"),
command.onTabComplete(
null, null, "basesettings", new String[] {"pocket", "mobs", "p"}
)
);
assertEquals(
List.of("enable"),
command.onTabComplete(
null, null, "basesettings",
new String[] {"pocket", "mobs", "hostile", "e"}
)
);
for (String setting : List.of("navigation", "flight", "border", "spawnable", "bossbar")) { for (String setting : List.of("navigation", "flight", "border", "spawnable", "bossbar")) {
assertEquals( assertEquals(
List.of("disable"), List.of("disable"),
@@ -198,6 +241,171 @@ final class BaseSettingsCommandTest {
verify(inventory).setStorageContents(any(ItemStack[].class)); 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 pocketOwnerCanEnableHostileMobSpawningWithoutChangingPassive() 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);
PocketBaseState current = new PocketBaseState(
playerId, 1, java.util.Optional.empty(), java.util.Optional.empty(),
false, true, PocketBaseBiome.THE_VOID
);
when(pocketBases.state(playerId)).thenReturn(current);
when(pocketBases.setHostileMobSpawning(playerId, true)).thenReturn(
current.withHostileMobSpawningEnabled(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", "hostile", "enable"
});
verify(pocketBases).setHostileMobSpawning(playerId, true);
verify(pocketBases, never()).setPassiveMobSpawning(playerId, true);
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("hostile 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())
);
when(pocketBases.setPassiveMobSpawning(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", "passive", "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", "hostile", "enable"
});
verify(pocketBases, never()).setHostileMobSpawning(playerId, true);
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("Pocket Base I") && message.contains("locked")
));
}
@Test @Test
void failedPocketUpgradeRestoresPayment() throws Exception { void failedPocketUpgradeRestoresPayment() throws Exception {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -236,6 +444,39 @@ final class BaseSettingsCommandTest {
assertEquals(64, restored[0].getAmount()); assertEquals(64, restored[0].getAmount());
} }
@Test
void statusDisplaysPocketMobSpawningPreferences() {
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(), java.util.Optional.empty(),
true, false, PocketBaseBiome.THE_VOID
)
);
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("hostile spawning=enabled")
&& message.contains("passive spawning=disabled")
));
}
@Test @Test
void bossbarEnableIsIdempotent() { void bossbarEnableIsIdempotent() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -25,6 +25,8 @@ final class PluginSettingsTest {
assertEquals(16, settings.pocketBaseUpgradeCost()); assertEquals(16, settings.pocketBaseUpgradeCost());
assertEquals("DIAMOND_BLOCK", settings.pocketBaseCurrencyMaterial()); assertEquals("DIAMOND_BLOCK", settings.pocketBaseCurrencyMaterial());
assertEquals("DIAMOND_BLOCK", settings.pocketBasePortalFrameMaterial()); assertEquals("DIAMOND_BLOCK", settings.pocketBasePortalFrameMaterial());
assertEquals(16, settings.pocketBaseBiomeChangeCost());
assertEquals("NETHERITE_BLOCK", settings.pocketBaseBiomeCurrencyMaterial());
} }
@Test @Test
@@ -33,13 +35,17 @@ final class PluginSettingsTest {
"pocket-base-unlock-cost", 32, "pocket-base-unlock-cost", 32,
"pocket-base-upgrade-cost", 8, "pocket-base-upgrade-cost", 8,
"pocket-base-currency-material", "EMERALD_BLOCK", "pocket-base-currency-material", "EMERALD_BLOCK",
"pocket-base-portal-frame-material", "GOLD_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(32, settings.pocketBaseUnlockCost());
assertEquals(8, settings.pocketBaseUpgradeCost()); assertEquals(8, settings.pocketBaseUpgradeCost());
assertEquals("EMERALD_BLOCK", settings.pocketBaseCurrencyMaterial()); assertEquals("EMERALD_BLOCK", settings.pocketBaseCurrencyMaterial());
assertEquals("GOLD_BLOCK", settings.pocketBasePortalFrameMaterial()); assertEquals("GOLD_BLOCK", settings.pocketBasePortalFrameMaterial());
assertEquals(12, settings.pocketBaseBiomeChangeCost());
assertEquals("NETHERITE_INGOT", settings.pocketBaseBiomeCurrencyMaterial());
} }
@Test @Test
@@ -2,6 +2,7 @@ package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -85,6 +86,114 @@ final class PocketBaseControllerTest {
verify(event).setCancelled(true); 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 @Test
void placementOutsideUnlockedPocketBoundaryIsCancelled() { void placementOutsideUnlockedPocketBoundaryIsCancelled() {
UUID ownerId = UUID.randomUUID(); UUID ownerId = UUID.randomUUID();
@@ -0,0 +1,169 @@
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, false,
PocketBaseBiome.THE_VOID
));
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 persistsAndAppliesHostileMobSpawningWithoutChangingPassive() throws Exception {
UUID ownerId = UUID.randomUUID();
PocketBaseState current = new PocketBaseState(
ownerId, 1, Optional.empty(), Optional.empty(), false, true,
PocketBaseBiome.THE_VOID
);
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.setHostileMobSpawning(ownerId, true);
assertEquals(true, updated.hostileMobSpawningEnabled());
assertEquals(true, updated.passiveMobSpawningEnabled());
InOrder order = inOrder(worlds, states);
order.verify(worlds).setMobSpawning(ownerId, true, 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 restoresBothWorldSettingsWhenPersistenceFails() throws Exception {
UUID ownerId = UUID.randomUUID();
PocketBaseState current = new PocketBaseState(
ownerId, 1, Optional.empty(), Optional.empty(), true, false,
PocketBaseBiome.THE_VOID
);
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.setPassiveMobSpawning(ownerId, true));
verify(worlds).setMobSpawning(ownerId, true, true);
verify(worlds).setMobSpawning(ownerId, true, false);
}
}
@@ -1,6 +1,8 @@
package games.dmg.spigotbase; package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals; 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.ArgumentMatchers.any;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
@@ -9,6 +11,7 @@ import static org.mockito.Mockito.when;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.Server; import org.bukkit.Server;
@@ -28,9 +31,12 @@ final class PocketBaseWorldServiceTest {
when(server.createWorld(any(WorldCreator.class))).thenReturn(world); when(server.createWorld(any(WorldCreator.class))).thenReturn(world);
when(world.getUID()).thenReturn(worldId); when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("pocket"); when(world.getName()).thenReturn("pocket");
when(world.getMinHeight()).thenReturn(0);
when(world.getMaxHeight()).thenReturn(8);
PocketBaseWorldService service = new PocketBaseWorldService( PocketBaseWorldService service = new PocketBaseWorldService(
server, server,
new PluginSettingsProvider(PluginSettings.from(Map.of())) new PluginSettingsProvider(PluginSettings.from(Map.of())),
ignored -> null
); );
service.expand(ownerId, 0, 1); service.expand(ownerId, 0, 1);
@@ -41,9 +47,87 @@ final class PocketBaseWorldServiceTest {
verify(blocks.get(new BlockPosition(-2, 65, 0))) verify(blocks.get(new BlockPosition(-2, 65, 0)))
.setType(Material.DIAMOND_BLOCK, false); .setType(Material.DIAMOND_BLOCK, false);
verify(world).setSpawnLocation(0, 65, 5); verify(world).setSpawnLocation(0, 65, 5);
verify(world).setSpawnFlags(false, false);
assertEquals(ownerId, service.ownerForWorld(worldId).orElseThrow()); 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(), Optional.empty(), true, false,
PocketBaseBiome.THE_VOID
)
));
verify(world).setSpawnFlags(true, false);
}
@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 @Test
void laterLevelAddsRingWithoutOverwritingExistingTerrain() { void laterLevelAddsRingWithoutOverwritingExistingTerrain() {
UUID ownerId = UUID.randomUUID(); UUID ownerId = UUID.randomUUID();
@@ -53,9 +137,12 @@ final class PocketBaseWorldServiceTest {
when(server.createWorld(any(WorldCreator.class))).thenReturn(world); when(server.createWorld(any(WorldCreator.class))).thenReturn(world);
when(world.getUID()).thenReturn(UUID.randomUUID()); when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.getName()).thenReturn("pocket"); when(world.getName()).thenReturn("pocket");
when(world.getMinHeight()).thenReturn(0);
when(world.getMaxHeight()).thenReturn(8);
PocketBaseWorldService service = new PocketBaseWorldService( PocketBaseWorldService service = new PocketBaseWorldService(
server, server,
new PluginSettingsProvider(PluginSettings.from(Map.of())) new PluginSettingsProvider(PluginSettings.from(Map.of())),
ignored -> null
); );
Block existingCenter = world.getBlockAt(0, 64, 0); Block existingCenter = world.getBlockAt(0, 64, 0);
@@ -65,6 +152,8 @@ final class PocketBaseWorldServiceTest {
.setType(Material.GRASS_BLOCK, false); .setType(Material.GRASS_BLOCK, false);
verify(existingCenter, never()) verify(existingCenter, never())
.setType(any(Material.class), org.mockito.ArgumentMatchers.anyBoolean()); .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) { private static Map<BlockPosition, Block> blocks(World world) {
@@ -1,7 +1,10 @@
package games.dmg.spigotbase; package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals; 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.nio.file.Path;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -22,7 +25,13 @@ final class YamlPocketBaseRepositoryTest {
7, 7,
Optional.of(new PocketPortalLocation( Optional.of(new PocketPortalLocation(
worldId, "world", 10, 65, -4, PocketPortalAxis.X worldId, "world", 10, 65, -4, PocketPortalAxis.X
)) )),
Optional.of(new PocketPortalLocation(
worldId, "pocket", 20, 65, 12, PocketPortalAxis.Z
)),
true,
false,
PocketBaseBiome.CRIMSON_FOREST
); );
YamlPocketBaseRepository repository = new YamlPocketBaseRepository( YamlPocketBaseRepository repository = new YamlPocketBaseRepository(
temporaryDirectory.resolve("pocket-bases.yml") temporaryDirectory.resolve("pocket-bases.yml")
@@ -32,4 +41,33 @@ final class YamlPocketBaseRepositoryTest {
assertEquals(expected, repository.load().get(ownerId)); 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.hostileMobSpawningEnabled());
assertFalse(loaded.passiveMobSpawningEnabled());
assertTrue(loaded.returnPortal().isEmpty());
assertEquals(PocketBaseBiome.THE_VOID, loaded.biome());
}
@Test
void migratesLegacyMobPreferenceToBothCategories() throws Exception {
UUID ownerId = UUID.randomUUID();
Path stateFile = temporaryDirectory.resolve("pocket-bases.yml");
Files.writeString(stateFile, "owners:\n " + ownerId
+ ":\n level: 1\n mob-spawning-enabled: true\n");
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(stateFile);
PocketBaseState loaded = repository.load().get(ownerId);
assertTrue(loaded.hostileMobSpawningEnabled());
assertTrue(loaded.passiveMobSpawningEnabled());
}
} }