feat(pocket-base): add purchasable biome changes
Release / release (push) Successful in 2m54s
CI / build (push) Successful in 1m16s

This commit is contained in:
dmg
2026-08-24 10:32:32 -04:00
parent 4518478ea1
commit 6ea5bc16f4
20 changed files with 598 additions and 21 deletions
+3
View File
@@ -30,6 +30,7 @@ The plugin JAR is written to `build/libs/`.
/basesettings upgrade /basesettings upgrade
/basesettings pocket upgrade /basesettings pocket upgrade
/basesettings pocket mobs <enable|disable> /basesettings pocket mobs <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>
@@ -46,6 +47,8 @@ After 250 Survival-mode block placements anywhere by default, the spawnable over
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>`. 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 ## Administration
The `spigotbase.admin` permission is granted to server operators by default. `/homeadmin` aliases `/baseadmin`. The `spigotbase.admin` permission is granted to server operators by default. `/homeadmin` aliases `/baseadmin`.
+8
View File
@@ -105,3 +105,11 @@ description: Chronological record of material decisions affecting the Spigot Bas
- Persisted one active return portal location while retaining the generated portal as the default for existing Pocket Bases. - 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. - 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`. - 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`.
+1
View File
@@ -24,3 +24,4 @@ description: Catalog of user stories for the Spigot Base plugin.
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) 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)
@@ -26,3 +26,4 @@ As a **Pocket Base owner**, I want to enable or disable natural mob spawning in
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.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) - [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-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;
@@ -70,6 +73,10 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
&& arguments[1].equalsIgnoreCase("mobs")) { && arguments[1].equalsIgnoreCase("mobs")) {
return updatePocketMobSpawning(player, arguments[2]); 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")) { if (arguments.length == 2 && arguments[0].equalsIgnoreCase("visitors")) {
return updateVisitors(player, state, arguments[1]); return updateVisitors(player, state, arguments[1]);
} }
@@ -180,6 +187,59 @@ 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 mode) { private boolean updatePocketMobSpawning(Player player, String mode) {
if (pocketBases == null) { if (pocketBases == null) {
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable."); player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
@@ -452,7 +512,9 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
player.sendMessage(colorForPrerequisite(owner.baseLevel() >= 4 && owner.base().isPresent()) player.sendMessage(colorForPrerequisite(owner.baseLevel() >= 4 && owner.base().isPresent())
+ "Pocket Base " + pocket.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; mob spawning=" + " → /basesettings pocket upgrade; type="
+ pocket.biome().worldType().commandName() + "/" + pocket.biome().commandName()
+ "; mob spawning="
+ (pocket.mobSpawningEnabled() ? "enabled" : "disabled")); + (pocket.mobSpawningEnabled() ? "enabled" : "disabled"));
} }
@@ -485,18 +547,34 @@ 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", "mobs"); 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") if (arguments.length == 3 && arguments[0].equalsIgnoreCase("pocket")) {
&& arguments[1].equalsIgnoreCase("mobs")) {
String prefix = arguments[2].toLowerCase(Locale.ROOT); String prefix = arguments[2].toLowerCase(Locale.ROOT);
if (arguments[1].equalsIgnoreCase("mobs")) {
return ENABLE_MODES.stream().filter(mode -> mode.startsWith(prefix)).toList(); 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(); return List.of();
} }
@@ -557,6 +635,7 @@ 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|pocket mobs <enable|disable>" + "[status|upgrade|pocket upgrade|pocket mobs <enable|disable>"
+ "|pocket type <void|nether|overworld> <subtype>"
+ "|visitors <allowed|blocked>|navigation <enable|disable>" + "|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();
}
}
@@ -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));
} }
@@ -71,6 +71,27 @@ final class PocketBaseManager {
return states.updateAndSave(ownerId, state -> state.withReturnPortal(portal)); 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 setMobSpawning(UUID ownerId, boolean enabled) throws IOException {
PocketBaseState current = states.state(ownerId); PocketBaseState current = states.state(ownerId);
if (current.level() < 1) { if (current.level() < 1) {
@@ -8,7 +8,8 @@ public record PocketBaseState(
int level, int level,
Optional<PocketPortalLocation> entrance, Optional<PocketPortalLocation> entrance,
Optional<PocketPortalLocation> returnPortal, Optional<PocketPortalLocation> returnPortal,
boolean mobSpawningEnabled boolean mobSpawningEnabled,
PocketBaseBiome biome
) { ) {
public PocketBaseState { public PocketBaseState {
if (ownerId == null) { if (ownerId == null) {
@@ -19,6 +20,7 @@ public record PocketBaseState(
} }
entrance = entrance == null ? Optional.empty() : entrance; entrance = entrance == null ? Optional.empty() : entrance;
returnPortal = returnPortal == null ? Optional.empty() : returnPortal; returnPortal = returnPortal == null ? Optional.empty() : returnPortal;
biome = biome == null ? PocketBaseBiome.THE_VOID : biome;
if (level == 0 && (entrance.isPresent() || returnPortal.isPresent())) { if (level == 0 && (entrance.isPresent() || returnPortal.isPresent())) {
throw new IllegalArgumentException("a locked Pocket Base cannot have a portal"); throw new IllegalArgumentException("a locked Pocket Base cannot have a portal");
} }
@@ -29,7 +31,9 @@ public record PocketBaseState(
int level, int level,
Optional<PocketPortalLocation> entrance Optional<PocketPortalLocation> entrance
) { ) {
this(ownerId, level, entrance, Optional.empty(), false); this(
ownerId, level, entrance, Optional.empty(), false, PocketBaseBiome.THE_VOID
);
} }
public PocketBaseState( public PocketBaseState(
@@ -38,40 +42,63 @@ public record PocketBaseState(
Optional<PocketPortalLocation> entrance, Optional<PocketPortalLocation> entrance,
boolean mobSpawningEnabled boolean mobSpawningEnabled
) { ) {
this(ownerId, level, entrance, Optional.empty(), 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) { public static PocketBaseState locked(UUID ownerId) {
return new PocketBaseState( return new PocketBaseState(
ownerId, 0, Optional.empty(), Optional.empty(), false ownerId, 0, Optional.empty(), Optional.empty(), false,
PocketBaseBiome.THE_VOID
); );
} }
public PocketBaseState withLevel(int newLevel) { public PocketBaseState withLevel(int newLevel) {
return new PocketBaseState( return new PocketBaseState(
ownerId, newLevel, entrance, returnPortal, mobSpawningEnabled ownerId, newLevel, entrance, returnPortal, mobSpawningEnabled, biome
); );
} }
public PocketBaseState withEntrance(PocketPortalLocation portal) { public PocketBaseState withEntrance(PocketPortalLocation portal) {
return new PocketBaseState( return new PocketBaseState(
ownerId, level, Optional.of(portal), returnPortal, mobSpawningEnabled ownerId, level, Optional.of(portal), returnPortal, mobSpawningEnabled, biome
); );
} }
public PocketBaseState withoutEntrance() { public PocketBaseState withoutEntrance() {
return new PocketBaseState( return new PocketBaseState(
ownerId, level, Optional.empty(), returnPortal, mobSpawningEnabled ownerId, level, Optional.empty(), returnPortal, mobSpawningEnabled, biome
); );
} }
public PocketBaseState withReturnPortal(PocketPortalLocation portal) { public PocketBaseState withReturnPortal(PocketPortalLocation portal) {
return new PocketBaseState( return new PocketBaseState(
ownerId, level, entrance, Optional.of(portal), mobSpawningEnabled ownerId, level, entrance, Optional.of(portal), mobSpawningEnabled, biome
); );
} }
public PocketBaseState withMobSpawningEnabled(boolean enabled) { public PocketBaseState withMobSpawningEnabled(boolean enabled) {
return new PocketBaseState(ownerId, level, entrance, returnPortal, enabled); return new PocketBaseState(ownerId, level, entrance, returnPortal, enabled, biome);
}
public PocketBaseState withBiome(PocketBaseBiome selectedBiome) {
return new PocketBaseState(
ownerId, level, entrance, returnPortal, mobSpawningEnabled, 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,43 @@ 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) {
setBiome(state.ownerId(), state.level(), state.biome());
setMobSpawning(state.ownerId(), state.mobSpawningEnabled()); setMobSpawning(state.ownerId(), state.mobSpawningEnabled());
} }
} }
} }
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 +74,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);
@@ -70,6 +95,12 @@ final class PocketBaseWorldService {
ensureWorld(ownerId).setSpawnFlags(enabled, 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) { 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);
} }
@@ -114,6 +145,40 @@ final class PocketBaseWorldService {
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();
}
}
}
@@ -44,7 +44,8 @@ public final class YamlPocketBaseRepository {
level, level,
loadPortal(yaml, path + ".entrance"), loadPortal(yaml, path + ".entrance"),
loadPortal(yaml, path + ".return-portal"), loadPortal(yaml, path + ".return-portal"),
yaml.getBoolean(path + ".mob-spawning-enabled", false) yaml.getBoolean(path + ".mob-spawning-enabled", false),
loadBiome(yaml, path)
); );
states.put(ownerId, state); states.put(ownerId, state);
} catch (IllegalArgumentException ignored) { } catch (IllegalArgumentException ignored) {
@@ -64,6 +65,8 @@ public final class YamlPocketBaseRepository {
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 + ".mob-spawning-enabled", state.mobSpawningEnabled()); 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.entrance().ifPresent(portal -> savePortal(yaml, path + ".entrance", portal));
state.returnPortal().ifPresent(portal -> state.returnPortal().ifPresent(portal ->
savePortal(yaml, path + ".return-portal", portal) savePortal(yaml, path + ".return-portal", portal)
@@ -87,6 +90,17 @@ public final class YamlPocketBaseRepository {
} }
} }
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
+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
@@ -100,6 +100,25 @@ final class BaseSettingsCommandTest {
List.of("mobs"), List.of("mobs"),
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "m"}) 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( assertEquals(
List.of("enable"), List.of("enable"),
command.onTabComplete( command.onTabComplete(
@@ -209,6 +228,73 @@ 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 @Test
void pocketOwnerCanEnableNaturalMobSpawning() throws Exception { void pocketOwnerCanEnableNaturalMobSpawning() throws Exception {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -366,6 +452,7 @@ final class BaseSettingsCommandTest {
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat( verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("Pocket Base 1") message -> message.contains("Pocket Base 1")
&& message.contains("type=void/the_void")
&& message.contains("mob spawning=disabled") && message.contains("mob spawning=disabled")
)); ));
} }
@@ -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
@@ -85,6 +85,57 @@ final class PocketBaseManagerTest {
); );
} }
@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 @Test
void restoresWorldSettingWhenPersistenceFails() throws Exception { void restoresWorldSettingWhenPersistenceFails() throws Exception {
UUID ownerId = UUID.randomUUID(); UUID ownerId = UUID.randomUUID();
@@ -31,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);
@@ -84,7 +87,8 @@ final class PocketBaseWorldServiceTest {
when(world.getUID()).thenReturn(UUID.randomUUID()); when(world.getUID()).thenReturn(UUID.randomUUID());
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.loadExisting(Map.of( service.loadExisting(Map.of(
@@ -95,6 +99,32 @@ final class PocketBaseWorldServiceTest {
verify(world).setSpawnFlags(true, 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 @Test
void laterLevelAddsRingWithoutOverwritingExistingTerrain() { void laterLevelAddsRingWithoutOverwritingExistingTerrain() {
UUID ownerId = UUID.randomUUID(); UUID ownerId = UUID.randomUUID();
@@ -104,9 +134,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);
@@ -116,6 +149,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) {
@@ -29,7 +29,8 @@ final class YamlPocketBaseRepositoryTest {
Optional.of(new PocketPortalLocation( Optional.of(new PocketPortalLocation(
worldId, "pocket", 20, 65, 12, PocketPortalAxis.Z worldId, "pocket", 20, 65, 12, PocketPortalAxis.Z
)), )),
true true,
PocketBaseBiome.CRIMSON_FOREST
); );
YamlPocketBaseRepository repository = new YamlPocketBaseRepository( YamlPocketBaseRepository repository = new YamlPocketBaseRepository(
temporaryDirectory.resolve("pocket-bases.yml") temporaryDirectory.resolve("pocket-bases.yml")
@@ -51,5 +52,6 @@ final class YamlPocketBaseRepositoryTest {
assertFalse(loaded.mobSpawningEnabled()); assertFalse(loaded.mobSpawningEnabled());
assertTrue(loaded.returnPortal().isEmpty()); assertTrue(loaded.returnPortal().isEmpty());
assertEquals(PocketBaseBiome.THE_VOID, loaded.biome());
} }
} }