feat(pocket-base): unlock flight by completing raids

This commit is contained in:
dmg
2026-08-29 00:04:46 -04:00
parent 02c6698268
commit f4511f9286
19 changed files with 474 additions and 19 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ After 250 Survival-mode block placements anywhere by default, the spawnable over
`/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown.
Base IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world. Players and non-player living mobs can travel through the entrance and active return portal; items, projectiles, and vehicles are not transported. 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>`.
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. Players and non-player living mobs can travel through the entrance and active return portal; items, projectiles, and vehicles are not transported. 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>`. An owner who wins a raid inside their own Pocket Base permanently unlocks owner-only Survival flight across the full build height and within the current Pocket Base boundary plus a 16-block warning buffer on every side.
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.
+7
View File
@@ -149,3 +149,10 @@ description: Chronological record of material decisions affecting the Spigot Bas
- Cleared accumulated fall distance whenever portal or void-return travel teleports an entity, preventing carried-over fall damage at the destination.
- Added regression coverage for players falling out of a Pocket Base and returning to the server spawn.
- Verified the fix and plugin artifact with `./gradlew clean check jar`.
## 2026-08-28 — Raid-earned Pocket Base flight
- Made a successful raid victory by the owner inside their own Pocket Base permanently unlock owner-only Survival flight.
- Limited Pocket Base flight to its expanding horizontal boundary plus a 16-block warning buffer while allowing the world's full build height.
- Persisted unlocks with safe locked defaults for existing records and exposed progress through chat status and the native settings dialog.
- Verified 140 tests and the Java 25 plugin artifact with `./gradlew clean check jar`.
+1
View File
@@ -26,3 +26,4 @@ description: Catalog of user stories for the Spigot Base plugin.
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)
20. [US-020: Control a base through a dialog UI](us-020-control-base-through-dialog-ui.md)
21. [US-021: Unlock Pocket Base flight](us-021-unlock-pocket-base-flight.md)
@@ -12,6 +12,7 @@ As a **player**, I want to inspect my progression and receive timely milestone f
## Acceptance criteria
- [x] `/basesettings status` shows the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths plus current visitor, navigation, flight, and boss-bar settings.
- [x] `/basesettings status` shows whether Pocket Base flight still requires a successful owner raid or has been unlocked.
- [x] Each path identifies earned levels, unmet prerequisites, current progress, the next threshold, and the next reward.
- [x] Locked secondary paths clearly identify Base I or another sequential level as their prerequisite.
- [x] Relevant qualifying activity briefly displays a configurable progress boss bar for the active milestone.
@@ -31,3 +32,4 @@ As a **player**, I want to inspect my progression and receive timely milestone f
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
- [US-004: Unlock and control base flight](us-004-unlock-and-control-base-flight.md)
- [US-010: Administer player progression](us-010-administer-player-progression.md)
- [US-021: Unlock Pocket Base flight](us-021-unlock-pocket-base-flight.md)
@@ -17,6 +17,7 @@ As a **server operator**, I want progression behavior to be configurable and dur
- [x] Radii, vertical ranges, durations, and prices reject unsafe or nonsensical values.
- [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
- [x] UUID-keyed state persists latest known names, base locations, counters, levels, relocation times, teleport times, visitor cooldowns, navigation preferences, flight preferences, boss-bar preferences, and visitor settings.
- [x] UUID-keyed Pocket Base state persists raid-earned flight unlocks, with missing legacy values defaulting safely to locked.
- [x] Cooldowns and relocation limits use real elapsed timestamps and continue while players are offline.
- [x] State is saved safely so a failed write does not replace valid persisted state with a partial document.
- [x] Corrupt, unknown, or invalid records are handled defensively and cannot silently grant progression or privileges.
@@ -28,3 +29,4 @@ As a **server operator**, I want progression behavior to be configurable and dur
- [US-003: Expand the base](us-003-expand-the-base.md)
- [US-010: Administer player progression](us-010-administer-player-progression.md)
- [US-012: Build and release the plugin](us-012-build-and-release-plugin.md)
- [US-021: Unlock Pocket Base flight](us-021-unlock-pocket-base-flight.md)
@@ -16,6 +16,7 @@ As a **player**, I want a dialog-based base dashboard so that I can inspect prog
- [x] `/basesettings status` retains the chat-based progression report.
- [x] The dialog summarizes current Base and Pocket Base progression.
- [x] Dialog controls cover visitors, navigation, flight, border, spawnable overlay, boss bar, Pocket Base mob spawning, upgrades, and biome selection when applicable.
- [x] The Pocket Base dialog shows whether owner flight still requires a successful raid or has been unlocked.
- [x] Controls indicate their current or locked state.
- [x] Purchases require confirmation and continue using existing validation, payment, and persistence logic.
- [x] `ui` is offered through command autocomplete.
@@ -32,3 +33,4 @@ As a **player**, I want a dialog-based base dashboard so that I can inspect prog
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
- [US-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md)
- [US-019: Change a Pocket Base biome](us-019-change-a-pocket-base-biome.md)
- [US-021: Unlock Pocket Base flight](us-021-unlock-pocket-base-flight.md)
@@ -0,0 +1,37 @@
---
type: User Story
title: "US-021: Unlock Pocket Base flight"
description: Let a Pocket Base owner permanently earn controlled flight by winning a raid in their own pocket world.
status: done
---
# US-021: Unlock Pocket Base flight
As a **Pocket Base owner**, I want to earn flight by completing a raid in my Pocket Base so that I can build and travel safely throughout my expanding pocket world.
## Acceptance criteria
- [x] Pocket Base flight begins locked for new owners and existing saved Pocket Bases.
- [x] A successful raid completed in a Pocket Base permanently unlocks flight only when that Pocket Base's owner is listed among the raid winners.
- [x] Raids completed outside the owner's Pocket Base and raids the owner does not win do not unlock flight.
- [x] Defeated, stopped, expired, and abandoned raids do not unlock flight.
- [x] Unlocking Pocket Base flight is free and does not consume blocks or other currency.
- [x] The owner receives a prominent one-time unlock notification after the qualifying raid.
- [x] In Survival mode, an owner with the unlock receives plugin-granted flight while inside their own Pocket Base.
- [x] Pocket Base flight spans the world's full build height and the current unlocked horizontal boundary plus 16 blocks on every side.
- [x] An actively flying owner receives a warning after leaving the unlocked Pocket Base boundary while remaining inside the 16-block buffer.
- [x] Moving beyond the 16-block buffer removes only flight granted by this plugin.
- [x] Pocket Base expansion automatically expands the corresponding flight area.
- [x] Visitors do not receive the owner's Pocket Base flight privilege.
- [x] Teleportation, world changes, game-mode changes, death, logout, and plugin shutdown do not leave unintended flight enabled.
- [x] The unlock persists across reconnects and restarts.
- [x] Chat status and the native settings dialog show whether Pocket Base flight is locked behind a successful raid or unlocked.
## Related
- [US-004: Unlock and control base flight](us-004-unlock-and-control-base-flight.md)
- [US-009: View progression and unlock notifications](us-009-view-progression-and-notifications.md)
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
- [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
- [US-020: Control a base through a dialog UI](us-020-control-base-through-dialog-ui.md)
@@ -1,21 +1,31 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.bukkit.ChatColor;
import org.bukkit.GameMode;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.raid.RaidFinishEvent;
import org.bukkit.inventory.ItemStack;
final class BaseFlightController implements Runnable {
final class BaseFlightController implements Runnable, Listener {
private static final int POCKET_FLIGHT_BUFFER = 16;
private final Server server;
private final BaseStateManager stateManager;
private final SecondaryProgressionService progressionService;
private final BaseBoundsService boundsService;
private final PluginSettingsProvider settings;
private final PocketBaseManager pocketBases;
private final Logger logger;
private final Set<UUID> grantedFlight = new HashSet<>();
private final Set<UUID> warned = new HashSet<>();
@@ -25,12 +35,60 @@ final class BaseFlightController implements Runnable {
SecondaryProgressionService progressionService,
BaseBoundsService boundsService,
PluginSettingsProvider settings
) {
this(server, stateManager, progressionService, boundsService, settings, null,
Logger.getLogger(BaseFlightController.class.getName()));
}
BaseFlightController(
Server server,
BaseStateManager stateManager,
SecondaryProgressionService progressionService,
BaseBoundsService boundsService,
PluginSettingsProvider settings,
PocketBaseManager pocketBases,
Logger logger
) {
this.server = server;
this.stateManager = stateManager;
this.progressionService = progressionService;
this.boundsService = boundsService;
this.settings = settings;
this.pocketBases = pocketBases;
this.logger = logger;
}
@EventHandler
public void onRaidFinish(RaidFinishEvent event) {
if (pocketBases == null) {
return;
}
Optional<UUID> owner = pocketBases.ownerForPocketWorld(event.getWorld().getUID());
if (owner.isEmpty() || event.getWinners().stream().noneMatch(
winner -> winner.getUniqueId().equals(owner.orElseThrow()))) {
return;
}
UUID ownerId = owner.orElseThrow();
if (pocketBases.state(ownerId).flightUnlocked()) {
return;
}
try {
pocketBases.unlockFlight(ownerId);
} catch (IOException | RuntimeException exception) {
logger.log(Level.SEVERE, "Could not unlock Pocket Base flight", exception);
return;
}
event.getWinners().stream()
.filter(winner -> winner.getUniqueId().equals(ownerId))
.findFirst()
.orElseThrow()
.sendTitle(
ChatColor.GOLD + "Pocket Base Flight Unlocked",
ChatColor.YELLOW + "Flight is now available in your Pocket Base",
settings.current().titleFadeInTicks(),
settings.current().titleStayTicks(),
settings.current().titleFadeOutTicks()
);
}
@Override
@@ -89,8 +147,20 @@ final class BaseFlightController implements Runnable {
}
private void applyFlight(Player player, PlayerState state) {
if (player.getGameMode() != GameMode.SURVIVAL
|| !state.flightEnabled() || state.flightLevel() < 1 || state.base().isEmpty()) {
if (player.getGameMode() != GameMode.SURVIVAL) {
removeGrantedFlight(player);
return;
}
if (pocketBases != null) {
Optional<UUID> pocketOwner = pocketBases.ownerForPocketWorld(
player.getWorld().getUID()
);
if (pocketOwner.isPresent()) {
applyPocketFlight(player, pocketOwner.orElseThrow());
return;
}
}
if (!state.flightEnabled() || state.flightLevel() < 1 || state.base().isEmpty()) {
removeGrantedFlight(player);
return;
}
@@ -127,6 +197,43 @@ final class BaseFlightController implements Runnable {
}
}
private void applyPocketFlight(Player player, UUID ownerId) {
PocketBaseState pocket = pocketBases.state(ownerId);
if (!ownerId.equals(player.getUniqueId()) || !pocket.flightUnlocked()
|| pocket.level() < 1) {
removeGrantedFlight(player);
return;
}
int halfSize = pocketBases.policy().size(pocket.level()) / 2;
int x = player.getLocation().getBlockX();
int z = player.getLocation().getBlockZ();
if (x < -halfSize - POCKET_FLIGHT_BUFFER
|| x >= halfSize + POCKET_FLIGHT_BUFFER
|| z < -halfSize - POCKET_FLIGHT_BUFFER
|| z >= halfSize + POCKET_FLIGHT_BUFFER) {
removeGrantedFlight(player);
return;
}
if (!player.getAllowFlight()) {
player.setAllowFlight(true);
grantedFlight.add(player.getUniqueId());
}
boolean inBuffer = !pocketBases.policy().contains(pocket.level(), x, z);
if (inBuffer && player.isFlying()) {
if (warned.add(player.getUniqueId())) {
player.sendTitle(
ChatColor.RED + "Leaving Your Pocket Base",
ChatColor.YELLOW + "Turn back before Pocket Base flight ends",
0,
Math.min(30, settings.current().titleStayTicks()),
settings.current().titleFadeOutTicks()
);
}
} else {
warned.remove(player.getUniqueId());
}
}
private boolean withinVerticalRange(Player player, PlayerState state, BaseLocation base) {
int y = player.getLocation().getBlockY();
if (state.flightLevel() == 3) {
@@ -565,7 +565,9 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
+ "; hostile spawning="
+ (pocket.hostileMobSpawningEnabled() ? "enabled" : "disabled")
+ "; passive spawning="
+ (pocket.passiveMobSpawningEnabled() ? "enabled" : "disabled"));
+ (pocket.passiveMobSpawningEnabled() ? "enabled" : "disabled")
+ "; flight="
+ (pocket.flightUnlocked() ? "unlocked" : "complete a raid to unlock"));
}
private void showCooldownPath(Player player, PlayerState state) {
@@ -159,7 +159,10 @@ final class BaseSettingsDialogFactory {
: "Biome: " + pocket.biome().worldType().commandName() + "/"
+ pocket.biome().commandName() + "\nHostile mobs: "
+ enabled(pocket.hostileMobSpawningEnabled()) + " • Passive mobs: "
+ enabled(pocket.passiveMobSpawningEnabled())
+ enabled(pocket.passiveMobSpawningEnabled()) + "\nFlight: "
+ (pocket.flightUnlocked()
? "Unlocked"
: "Complete a raid to unlock")
),
dialogs,
1,
@@ -92,6 +92,17 @@ final class PocketBaseManager {
}
}
PocketBaseState unlockFlight(UUID ownerId) throws IOException {
PocketBaseState current = states.state(ownerId);
if (current.level() < 1) {
throw new IllegalStateException("Pocket Base I is still locked");
}
if (current.flightUnlocked()) {
return current;
}
return states.updateAndSave(ownerId, PocketBaseState::withFlightUnlocked);
}
PocketBaseState setHostileMobSpawning(UUID ownerId, boolean enabled)
throws IOException {
PocketBaseState current = states.state(ownerId);
@@ -10,7 +10,8 @@ public record PocketBaseState(
Optional<PocketPortalLocation> returnPortal,
boolean hostileMobSpawningEnabled,
boolean passiveMobSpawningEnabled,
PocketBaseBiome biome
PocketBaseBiome biome,
boolean flightUnlocked
) {
public PocketBaseState {
if (ownerId == null) {
@@ -27,6 +28,21 @@ public record PocketBaseState(
}
}
public PocketBaseState(
UUID ownerId,
int level,
Optional<PocketPortalLocation> entrance,
Optional<PocketPortalLocation> returnPortal,
boolean hostileMobSpawningEnabled,
boolean passiveMobSpawningEnabled,
PocketBaseBiome biome
) {
this(
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, false
);
}
public PocketBaseState(
UUID ownerId,
int level,
@@ -34,63 +50,70 @@ public record PocketBaseState(
) {
this(
ownerId, level, entrance, Optional.empty(), false, false,
PocketBaseBiome.THE_VOID
PocketBaseBiome.THE_VOID, false
);
}
public static PocketBaseState locked(UUID ownerId) {
return new PocketBaseState(
ownerId, 0, Optional.empty(), Optional.empty(), false, false,
PocketBaseBiome.THE_VOID
PocketBaseBiome.THE_VOID, false
);
}
public PocketBaseState withLevel(int newLevel) {
return new PocketBaseState(
ownerId, newLevel, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome
passiveMobSpawningEnabled, biome, flightUnlocked
);
}
public PocketBaseState withEntrance(PocketPortalLocation portal) {
return new PocketBaseState(
ownerId, level, Optional.of(portal), returnPortal,
hostileMobSpawningEnabled, passiveMobSpawningEnabled, biome
hostileMobSpawningEnabled, passiveMobSpawningEnabled, biome, flightUnlocked
);
}
public PocketBaseState withoutEntrance() {
return new PocketBaseState(
ownerId, level, Optional.empty(), returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome
passiveMobSpawningEnabled, biome, flightUnlocked
);
}
public PocketBaseState withReturnPortal(PocketPortalLocation portal) {
return new PocketBaseState(
ownerId, level, entrance, Optional.of(portal), hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome
passiveMobSpawningEnabled, biome, flightUnlocked
);
}
public PocketBaseState withHostileMobSpawningEnabled(boolean enabled) {
return new PocketBaseState(
ownerId, level, entrance, returnPortal, enabled,
passiveMobSpawningEnabled, biome
passiveMobSpawningEnabled, biome, flightUnlocked
);
}
public PocketBaseState withPassiveMobSpawningEnabled(boolean enabled) {
return new PocketBaseState(
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
enabled, biome
enabled, biome, flightUnlocked
);
}
public PocketBaseState withBiome(PocketBaseBiome selectedBiome) {
return new PocketBaseState(
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, selectedBiome
passiveMobSpawningEnabled, selectedBiome, flightUnlocked
);
}
public PocketBaseState withFlightUnlocked() {
return new PocketBaseState(
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, true
);
}
}
@@ -64,7 +64,8 @@ public final class SpigotBasePlugin extends JavaPlugin {
settingsProvider
);
flightController = new BaseFlightController(
getServer(), stateManager, secondaryProgressionService, boundsService, settingsProvider
getServer(), stateManager, secondaryProgressionService, boundsService,
settingsProvider, pocketBaseManager, getLogger()
);
VisitorPolicy visitorPolicy = new VisitorPolicy();
teleportManager = new BaseTeleportManager(
@@ -84,6 +85,7 @@ public final class SpigotBasePlugin extends JavaPlugin {
getLogger()
);
getServer().getPluginManager().registerEvents(progressListener, this);
getServer().getPluginManager().registerEvents(flightController, this);
getServer().getPluginManager().registerEvents(teleportManager, this);
getServer().getPluginManager().registerEvents(pocketBaseController, this);
@@ -46,7 +46,8 @@ public final class YamlPocketBaseRepository {
loadPortal(yaml, path + ".return-portal"),
loadMobPreference(yaml, path, "hostile-mob-spawning-enabled"),
loadMobPreference(yaml, path, "passive-mob-spawning-enabled"),
loadBiome(yaml, path)
loadBiome(yaml, path),
yaml.getBoolean(path + ".flight-unlocked", false)
);
states.put(ownerId, state);
} catch (IllegalArgumentException ignored) {
@@ -73,6 +74,7 @@ public final class YamlPocketBaseRepository {
path + ".passive-mob-spawning-enabled",
state.passiveMobSpawningEnabled()
);
yaml.set(path + ".flight-unlocked", state.flightUnlocked());
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));
@@ -12,11 +12,14 @@ import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.raid.RaidFinishEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.BeforeEach;
@@ -47,6 +50,7 @@ final class BaseFlightControllerTest {
PlayerInventory inventory = mock(PlayerInventory.class);
doReturn(List.of(player)).when(server).getOnlinePlayers();
when(server.getPlayer(playerId)).thenReturn(player);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Alex");
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
@@ -62,6 +66,219 @@ final class BaseFlightControllerTest {
when(player.getAllowFlight()).thenReturn(true);
}
@Test
void ownerWinningRaidInOwnPocketBaseUnlocksFlight() throws Exception {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Player winner = mock(Player.class);
RaidFinishEvent event = mock(RaidFinishEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState locked = new PocketBaseState(playerId, 1, Optional.empty());
PocketBaseState unlocked = locked.withFlightUnlocked();
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(event.getWorld()).thenReturn(pocketWorld);
when(event.getWinners()).thenReturn(List.of(winner));
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(winner.getUniqueId()).thenReturn(playerId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(locked);
when(pocketBases.unlockFlight(playerId)).thenReturn(unlocked);
pocketController.onRaidFinish(event);
verify(pocketBases).unlockFlight(playerId);
verify(winner).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
}
@Test
void raidDoesNotUnlockFlightWhenPocketOwnerIsNotAWinner() throws Exception {
UUID pocketWorldId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Player visitor = mock(Player.class);
RaidFinishEvent event = mock(RaidFinishEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(event.getWorld()).thenReturn(pocketWorld);
when(event.getWinners()).thenReturn(List.of(visitor));
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(visitor.getUniqueId()).thenReturn(UUID.randomUUID());
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
pocketController.onRaidFinish(event);
verify(pocketBases, never()).unlockFlight(ownerId);
}
@Test
void unlockedOwnerReceivesFlightInsidePocketBaseBuffer() {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked();
PlayerState noNormalFlight = PlayerState.newPlayer(playerId, "Alex");
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(noNormalFlight);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(location.getX()).thenReturn(40.5);
when(location.getZ()).thenReturn(0.5);
when(location.getBlockX()).thenReturn(40);
when(location.getBlockZ()).thenReturn(0);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
pocketController.run();
verify(player).setAllowFlight(true);
}
@Test
void flyingInPocketBaseBufferShowsLeavingWarning() {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked();
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.isFlying()).thenReturn(true);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(location.getBlockX()).thenReturn(40);
when(location.getBlockZ()).thenReturn(0);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
pocketController.run();
verify(player).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
}
@Test
void pocketBaseFlightEndsBeyondSixteenBlockBuffer() {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked();
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(location.getBlockX()).thenReturn(40, 48);
when(location.getBlockZ()).thenReturn(0);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
pocketController.run();
pocketController.run();
verify(player).setFlying(false);
verify(player).setAllowFlight(false);
}
@Test
void visitorDoesNotReceiveUnlockedOwnersPocketBaseFlight() {
UUID ownerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked();
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(pocket);
pocketController.run();
verify(player, never()).setAllowFlight(true);
}
@Test
void walkingInWarningBufferDoesNotShowLeavingBaseWarning() {
when(player.isFlying()).thenReturn(false);
@@ -489,6 +489,7 @@ final class BaseSettingsCommandTest {
&& message.contains("type=void/the_void")
&& message.contains("hostile spawning=enabled")
&& message.contains("passive spawning=disabled")
&& message.contains("flight=complete a raid to unlock")
));
}
@@ -59,6 +59,13 @@ final class BaseSettingsDialogFactoryTest {
specification
);
assertTrue(root.content().message().contains("Base 4/4"));
BaseSettingsDialogFactory.ListSpec pocketSettings = assertInstanceOf(
BaseSettingsDialogFactory.ListSpec.class,
root.dialogs().get(3)
);
assertTrue(pocketSettings.content().message().contains(
"Flight: Complete a raid to unlock"
));
List<String> commands = commands(root);
assertTrue(commands.contains("/basesettings status"));
assertTrue(commands.contains("/basesettings visitors blocked"));
@@ -2,6 +2,7 @@ package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@@ -58,6 +59,32 @@ final class PocketBaseManagerTest {
assertEquals(generated, manager.returnPortal(ownerId));
}
@Test
void persistsPocketBaseFlightUnlock() throws Exception {
UUID ownerId = UUID.randomUUID();
PocketBaseState current = new PocketBaseState(ownerId, 1, Optional.empty());
PocketBaseStateManager states = mock(PocketBaseStateManager.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,
mock(PocketBaseWorldService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
PocketBaseState updated = manager.unlockFlight(ownerId);
assertTrue(updated.flightUnlocked());
verify(states).updateAndSave(
org.mockito.ArgumentMatchers.eq(ownerId), org.mockito.ArgumentMatchers.any()
);
}
@Test
void persistsAndAppliesHostileMobSpawningWithoutChangingPassive() throws Exception {
UUID ownerId = UUID.randomUUID();
@@ -31,7 +31,8 @@ final class YamlPocketBaseRepositoryTest {
)),
true,
false,
PocketBaseBiome.CRIMSON_FOREST
PocketBaseBiome.CRIMSON_FOREST,
true
);
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(
temporaryDirectory.resolve("pocket-bases.yml")
@@ -55,6 +56,7 @@ final class YamlPocketBaseRepositoryTest {
assertFalse(loaded.passiveMobSpawningEnabled());
assertTrue(loaded.returnPortal().isEmpty());
assertEquals(PocketBaseBiome.THE_VOID, loaded.biome());
assertFalse(loaded.flightUnlocked());
}
@Test