diff --git a/README.md b/README.md index fe350f9..10ac3e9 100644 --- a/README.md +++ b/README.md @@ -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 `. +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 `. 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 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. diff --git a/design/log.md b/design/log.md index 5778a1e..7b1ea2d 100644 --- a/design/log.md +++ b/design/log.md @@ -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`. diff --git a/design/user-stories/index.md b/design/user-stories/index.md index 6bfe5b1..1bf10da 100644 --- a/design/user-stories/index.md +++ b/design/user-stories/index.md @@ -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) diff --git a/design/user-stories/us-009-view-progression-and-notifications.md b/design/user-stories/us-009-view-progression-and-notifications.md index d81f198..602c207 100644 --- a/design/user-stories/us-009-view-progression-and-notifications.md +++ b/design/user-stories/us-009-view-progression-and-notifications.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) diff --git a/design/user-stories/us-011-configure-and-persist-progression.md b/design/user-stories/us-011-configure-and-persist-progression.md index 7f44b47..fd77c40 100644 --- a/design/user-stories/us-011-configure-and-persist-progression.md +++ b/design/user-stories/us-011-configure-and-persist-progression.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) diff --git a/design/user-stories/us-020-control-base-through-dialog-ui.md b/design/user-stories/us-020-control-base-through-dialog-ui.md index e11d372..febb414 100644 --- a/design/user-stories/us-020-control-base-through-dialog-ui.md +++ b/design/user-stories/us-020-control-base-through-dialog-ui.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) diff --git a/design/user-stories/us-021-unlock-pocket-base-flight.md b/design/user-stories/us-021-unlock-pocket-base-flight.md new file mode 100644 index 0000000..e81d0f3 --- /dev/null +++ b/design/user-stories/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) diff --git a/src/main/java/games/dmg/spigotbase/BaseFlightController.java b/src/main/java/games/dmg/spigotbase/BaseFlightController.java index a831e8f..b191e32 100644 --- a/src/main/java/games/dmg/spigotbase/BaseFlightController.java +++ b/src/main/java/games/dmg/spigotbase/BaseFlightController.java @@ -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 grantedFlight = new HashSet<>(); private final Set 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 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 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) { diff --git a/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java b/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java index 7477fbd..8baac61 100644 --- a/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java +++ b/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java @@ -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) { diff --git a/src/main/java/games/dmg/spigotbase/BaseSettingsDialogFactory.java b/src/main/java/games/dmg/spigotbase/BaseSettingsDialogFactory.java index 4bd3adb..7da89b6 100644 --- a/src/main/java/games/dmg/spigotbase/BaseSettingsDialogFactory.java +++ b/src/main/java/games/dmg/spigotbase/BaseSettingsDialogFactory.java @@ -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, diff --git a/src/main/java/games/dmg/spigotbase/PocketBaseManager.java b/src/main/java/games/dmg/spigotbase/PocketBaseManager.java index b3dc14f..0d30442 100644 --- a/src/main/java/games/dmg/spigotbase/PocketBaseManager.java +++ b/src/main/java/games/dmg/spigotbase/PocketBaseManager.java @@ -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); diff --git a/src/main/java/games/dmg/spigotbase/PocketBaseState.java b/src/main/java/games/dmg/spigotbase/PocketBaseState.java index 568905c..4cf3659 100644 --- a/src/main/java/games/dmg/spigotbase/PocketBaseState.java +++ b/src/main/java/games/dmg/spigotbase/PocketBaseState.java @@ -10,7 +10,8 @@ public record PocketBaseState( Optional 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 entrance, + Optional 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 ); } } diff --git a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java index 751db1f..84995d1 100644 --- a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java +++ b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java @@ -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); diff --git a/src/main/java/games/dmg/spigotbase/YamlPocketBaseRepository.java b/src/main/java/games/dmg/spigotbase/YamlPocketBaseRepository.java index b0c47ca..cdf7200 100644 --- a/src/main/java/games/dmg/spigotbase/YamlPocketBaseRepository.java +++ b/src/main/java/games/dmg/spigotbase/YamlPocketBaseRepository.java @@ -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)); diff --git a/src/test/java/games/dmg/spigotbase/BaseFlightControllerTest.java b/src/test/java/games/dmg/spigotbase/BaseFlightControllerTest.java index 839bf47..02c06ac 100644 --- a/src/test/java/games/dmg/spigotbase/BaseFlightControllerTest.java +++ b/src/test/java/games/dmg/spigotbase/BaseFlightControllerTest.java @@ -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); diff --git a/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java b/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java index 4b6be68..46dfd09 100644 --- a/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java +++ b/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java @@ -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") )); } diff --git a/src/test/java/games/dmg/spigotbase/BaseSettingsDialogFactoryTest.java b/src/test/java/games/dmg/spigotbase/BaseSettingsDialogFactoryTest.java index 58d9a26..da43603 100644 --- a/src/test/java/games/dmg/spigotbase/BaseSettingsDialogFactoryTest.java +++ b/src/test/java/games/dmg/spigotbase/BaseSettingsDialogFactoryTest.java @@ -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 commands = commands(root); assertTrue(commands.contains("/basesettings status")); assertTrue(commands.contains("/basesettings visitors blocked")); diff --git a/src/test/java/games/dmg/spigotbase/PocketBaseManagerTest.java b/src/test/java/games/dmg/spigotbase/PocketBaseManagerTest.java index 9fdd42f..119be09 100644 --- a/src/test/java/games/dmg/spigotbase/PocketBaseManagerTest.java +++ b/src/test/java/games/dmg/spigotbase/PocketBaseManagerTest.java @@ -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 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(); diff --git a/src/test/java/games/dmg/spigotbase/YamlPocketBaseRepositoryTest.java b/src/test/java/games/dmg/spigotbase/YamlPocketBaseRepositoryTest.java index 2956448..32be3a0 100644 --- a/src/test/java/games/dmg/spigotbase/YamlPocketBaseRepositoryTest.java +++ b/src/test/java/games/dmg/spigotbase/YamlPocketBaseRepositoryTest.java @@ -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