feat(spawning): add spawnable block overlay
Release / release (push) Successful in 2m16s
CI / build (push) Successful in 1m3s

This commit is contained in:
dmg
2026-08-10 22:46:02 -04:00
parent 26aeb8c416
commit 259ea9410c
30 changed files with 988 additions and 46 deletions
@@ -11,6 +11,19 @@ final class AdminProgressCounterTest {
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
@Test
void settingTotalPlacementCounterUnlocksSpawnableOverlay() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
PlayerState updated = service.setProgress(
player,
ProgressCounter.TOTAL_PLACEMENTS,
250
);
assertEquals(250, updated.totalBlocksPlaced());
}
@Test
void settingPlacementCounterEvaluatesBaseAndWarmupTiers() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
@@ -53,6 +53,24 @@ final class AdminProgressionServiceTest {
assertFalse(updated.borderEnabled());
}
@Test
void completeResetClearsSpawnableOverlayProgressAndPreference() {
UUID playerId = UUID.randomUUID();
PlayerState player = PlayerState.newPlayer(playerId, "Alex")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withTotalBlocksPlaced(250)
.withSpawnableOverlayEnabled(true);
PlayerState updated = service.resetPath(player, ProgressionPath.BASE);
assertEquals(0, updated.totalBlocksPlaced());
assertFalse(updated.spawnableOverlayEnabled());
}
@Test
void rejectsUnknownLevel() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
@@ -1,6 +1,7 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
@@ -9,10 +10,13 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.command.CommandSender;
@@ -22,6 +26,46 @@ import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
final class BaseAdminCommandTest {
@Test
void adminCanEnableUnlockedSpawnableOverlayForPlayer() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
UUID playerId = UUID.randomUUID();
PlayerState target = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withTotalBlocksPlaced(250);
AtomicReference<PlayerState> updated = new AtomicReference<>();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.findByName("Builder")).thenReturn(Optional.of(target));
when(stateManager.update(any(), anyString(), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
PlayerState result = operation.apply(target);
updated.set(result);
return result;
});
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
stateManager,
new AdminProgressionService(
new PluginSettingsProvider(PluginSettings.from(Map.of()))
),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null);
command.onCommand(sender, null, "baseadmin",
new String[] {"setsetting", "Builder", "spawnable", "enable"});
}
assertTrue(updated.get().spawnableOverlayEnabled());
}
@Test
void invalidPathShowsFriendlyUsage() {
CommandSender sender = mock(CommandSender.class);
@@ -92,10 +136,23 @@ final class BaseAdminCommandTest {
new String[] {"setlevel", "Builder", ""})
);
assertEquals(
List.of("grass_dirt", "stone", "deepslate", "obsidian", "placements", "base_breaks"),
List.of(
"grass_dirt", "stone", "deepslate", "obsidian", "placements",
"total_placements", "base_breaks"
),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setprogress", "Builder", ""})
);
assertEquals(
List.of("spawnable"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setsetting", "Builder", ""})
);
assertEquals(
List.of("enable"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setsetting", "Builder", "spawnable", "e"})
);
assertEquals(
List.of("personal", "visitor", "all"),
command.onTabComplete(sender, null, "baseadmin",
@@ -108,6 +165,42 @@ final class BaseAdminCommandTest {
);
}
@Test
void adminCanChangeSpawnableOverlayUnlockThreshold() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
JavaPlugin plugin = mock(JavaPlugin.class);
FileConfiguration configuration = mock(FileConfiguration.class);
when(plugin.getConfig()).thenReturn(configuration);
when(configuration.get("spawnable-overlay-unlock-placements")).thenReturn(250);
when(configuration.getValues(false)).thenReturn(Map.of(
"spawnable-overlay-unlock-placements", 250
));
PluginSettingsProvider settings = new PluginSettingsProvider(
PluginSettings.from(Map.of())
);
BaseAdminCommand command = new BaseAdminCommand(
plugin,
mock(BaseStateManager.class),
mock(AdminProgressionService.class),
settings
);
try (MockedStatic<PluginSettingsValidator> validator =
mockStatic(PluginSettingsValidator.class)) {
validator.when(() -> PluginSettingsValidator.validateMaterials(
any(PluginSettings.class)
)).thenAnswer(invocation -> invocation.getArgument(0));
command.onCommand(sender, null, "baseadmin", new String[] {
"config", "spawnable-overlay-unlock-placements", "500"
});
}
verify(configuration).set("spawnable-overlay-unlock-placements", 500L);
verify(plugin).saveConfig();
assertEquals(500, settings.current().spawnableOverlayUnlockPlacements());
}
@Test
void autocompletesNumericConfigurationKeys() {
CommandSender sender = mock(CommandSender.class);
@@ -169,7 +262,10 @@ final class BaseAdminCommandTest {
);
assertEquals(
List.of("status", "setlevel", "setprogress", "clearcooldown", "reset", "config"),
List.of(
"status", "setlevel", "setprogress", "setsetting", "clearcooldown", "reset",
"config"
),
command.onTabComplete(sender, null, "baseadmin", new String[] {""})
);
}
@@ -0,0 +1,66 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator;
import org.bukkit.GameMode;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.plugin.Plugin;
import org.junit.jupiter.api.Test;
final class BaseProgressListenerTest {
@Test
void survivalPlacementOutsideBaseCountsTowardOverlayUnlock() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Builder");
AtomicReference<PlayerState> updated = new AtomicReference<>();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(current);
when(stateManager.update(any(), anyString(), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
PlayerState result = operation.apply(current);
updated.set(result);
return result;
});
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
World world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
Block block = mock(Block.class);
when(block.getWorld()).thenReturn(world);
BlockPlaceEvent event = mock(BlockPlaceEvent.class);
when(event.getPlayer()).thenReturn(player);
when(event.getBlockPlaced()).thenReturn(block);
PluginSettingsProvider settings = new PluginSettingsProvider(
PluginSettings.from(Map.of())
);
BaseProgressListener listener = new BaseProgressListener(
mock(Plugin.class),
stateManager,
mock(BaseProgressionService.class),
mock(SecondaryProgressionService.class),
mock(TeleportProgressionService.class),
new SpawnableOverlayProgressionService(settings),
new BaseBoundsService(settings),
settings
);
listener.onBlockPlace(event);
assertEquals(1, updated.get().totalBlocksPlaced());
assertEquals(0, updated.get().blocksPlacedInBase());
}
}
@@ -24,6 +24,22 @@ import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
final class BaseSettingsCommandTest {
@Test
void spawnableEnableIsIdempotentAfterUnlock() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withTotalBlocksPlaced(250);
CommandResult result = execute(current, "spawnable", "enable");
assertTrue(result.updated().spawnableOverlayEnabled());
}
@Test
void borderEnableIsIdempotent() {
UUID playerId = UUID.randomUUID();
@@ -73,7 +89,7 @@ final class BaseSettingsCommandTest {
List.of("allowed"),
command.onTabComplete(null, null, "basesettings", new String[] {"visitors", "a"})
);
for (String setting : List.of("navigation", "flight", "border", "bossbar")) {
for (String setting : List.of("navigation", "flight", "border", "spawnable", "bossbar")) {
assertEquals(
List.of("disable"),
command.onTabComplete(null, null, "basesettings", new String[] {setting, "d"})
@@ -90,7 +106,10 @@ final class BaseSettingsCommandTest {
);
assertEquals(
List.of("status", "upgrade", "visitors", "navigation", "flight", "border", "bossbar"),
List.of(
"status", "upgrade", "visitors", "navigation", "flight", "border",
"spawnable", "bossbar"
),
command.onTabComplete(null, null, "basesettings", new String[] {""})
);
}
@@ -204,12 +223,20 @@ final class BaseSettingsCommandTest {
command.onCommand(player, null, "basesettings", new String[] {"status"});
verify(player, times(2)).sendMessage(ChatColor.GOLD + "=== Base Progress ===");
verify(player, times(2)).sendMessage(
org.mockito.ArgumentMatchers.<String>argThat(message ->
message.contains("Spawnable Overlay:")
&& message.contains("0/250 placements")
&& message.contains("locked")
)
);
verify(player, times(2)).sendMessage(
org.mockito.ArgumentMatchers.<String>argThat(message ->
message.contains("Settings: visitors=")
&& message.contains("navigation=")
&& message.contains("flight=")
&& message.contains("border=")
&& message.contains("spawnable=")
&& message.contains("bossbar=")
)
);
@@ -11,6 +11,23 @@ import java.util.UUID;
import org.junit.jupiter.api.Test;
final class PlayerStateTest {
@Test
void spawnableOverlayProgressAndPreferenceCanBeUpdated() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
PlayerState updated = player
.withTotalBlocksPlaced(250)
.withSpawnableOverlayEnabled(true);
assertTrue(updated.spawnableOverlayEnabled());
assertTrue(updated.totalBlocksPlaced() == 250);
}
@Test
void borderVisualizationIsDisabledByDefaultAndCanBeEnabled() {
UUID playerId = UUID.randomUUID();
@@ -18,6 +18,7 @@ final class PluginSettingsTest {
assertEquals(86_400L, settings.relocationCooldownSeconds());
assertEquals(5, settings.flightWarningBuffer());
assertEquals(200, settings.teleportUnlockPlacements());
assertEquals(250, settings.spawnableOverlayUnlockPlacements());
assertEquals(10_800L, settings.initialTeleportCooldownSeconds());
assertEquals(128, settings.visitorUnlockDiamondCost());
}
@@ -0,0 +1,48 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.type.Slab;
import org.junit.jupiter.api.Test;
final class SpawnableBlockPolicyTest {
@Test
void acceptsDarkSolidSurfaceWithTwoPassableBlocksAbove() {
Block surface = mock(Block.class);
Block above = mock(Block.class);
Block secondAbove = mock(Block.class);
Slab slab = mock(Slab.class);
when(slab.getType()).thenReturn(Slab.Type.TOP);
when(surface.getBlockData()).thenReturn(slab);
when(surface.getRelative(BlockFace.UP)).thenReturn(above);
when(above.getRelative(BlockFace.UP)).thenReturn(secondAbove);
when(above.isPassable()).thenReturn(true);
when(secondAbove.isPassable()).thenReturn(true);
when(above.getLightFromBlocks()).thenReturn((byte) 0);
when(above.getLightFromSky()).thenReturn((byte) 0);
assertTrue(SpawnableBlockPolicy.isCandidate(surface));
}
@Test
void rejectsBlockLitSurface() {
Block surface = mock(Block.class);
Block above = mock(Block.class);
Block secondAbove = mock(Block.class);
Slab slab = mock(Slab.class);
when(slab.getType()).thenReturn(Slab.Type.TOP);
when(surface.getBlockData()).thenReturn(slab);
when(surface.getRelative(BlockFace.UP)).thenReturn(above);
when(above.getRelative(BlockFace.UP)).thenReturn(secondAbove);
when(above.isPassable()).thenReturn(true);
when(secondAbove.isPassable()).thenReturn(true);
when(above.getLightFromBlocks()).thenReturn((byte) 1);
assertFalse(SpawnableBlockPolicy.isCandidate(surface));
}
}
@@ -0,0 +1,85 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.type.Slab;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class SpawnableOverlayControllerTest {
@Test
void ownerReceivesBoundedRedParticlesForNearbyCandidates() {
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PlayerState state = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH)
.withTotalBlocksPlaced(250)
.withSpawnableOverlayEnabled(true);
Block surface = mock(Block.class);
Block above = mock(Block.class);
Block secondAbove = mock(Block.class);
Slab slab = mock(Slab.class);
when(slab.getType()).thenReturn(Slab.Type.TOP);
when(surface.getBlockData()).thenReturn(slab);
when(surface.getRelative(BlockFace.UP)).thenReturn(above);
when(above.getRelative(BlockFace.UP)).thenReturn(secondAbove);
when(above.isPassable()).thenReturn(true);
when(secondAbove.isPassable()).thenReturn(true);
when(above.getLightFromBlocks()).thenReturn((byte) 0);
when(above.getLightFromSky()).thenReturn((byte) 0);
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
when(world.getMinHeight()).thenReturn(-64);
when(world.getMaxHeight()).thenReturn(320);
when(world.getBlockAt(anyInt(), anyInt(), anyInt()))
.thenReturn(surface);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(new Location(world, 0.5, 65.0, 0.5));
Server server = mock(Server.class);
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(state);
PluginSettingsProvider settings = new PluginSettingsProvider(
PluginSettings.from(Map.of())
);
SpawnableOverlayController controller = new SpawnableOverlayController(
server,
stateManager,
new BaseBoundsService(settings),
settings
);
controller.run();
controller.run();
controller.run();
verify(player, atLeastOnce()).spawnParticle(
eq(Particle.DUST),
any(Location.class),
eq(1),
any(Particle.DustOptions.class)
);
}
}
@@ -0,0 +1,24 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class SpawnableOverlayProgressionServiceTest {
@Test
void twoHundredFiftiethPlacementUnlocksOverlay() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Builder")
.withTotalBlocksPlaced(249);
SpawnableOverlayProgressionService service = new SpawnableOverlayProgressionService(
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
SpawnableOverlayProgressionUpdate update = service.recordPlacement(player);
assertEquals(250, update.player().totalBlocksPlaced());
assertTrue(update.unlocked());
}
}
@@ -29,6 +29,8 @@ final class YamlBaseStateRepositoryTest {
Optional.of(Instant.ofEpochMilli(1_750_000_000_000L)),
Optional.of(Instant.ofEpochMilli(1_750_000_100_000L)),
Map.of(ownerId, Instant.ofEpochMilli(1_750_001_000_000L)),
true,
2_500,
true
);
PersistentState expected = new PersistentState(Map.of(playerId, player));
@@ -40,6 +42,26 @@ final class YamlBaseStateRepositoryTest {
assertEquals(expected, repository.load());
}
@Test
void olderStateUsesKnownInBasePlacementsAsTotalPlacementMinimum() throws Exception {
UUID playerId = UUID.randomUUID();
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, """
players:
%s:
name: Alex
blocks-placed-in-base: 225
""".formatted(playerId));
PlayerState player = new YamlBaseStateRepository(stateFile)
.load()
.players()
.get(playerId);
assertEquals(225, player.totalBlocksPlaced());
assertFalse(player.spawnableOverlayEnabled());
}
@Test
void olderStateWithoutBorderPreferenceDefaultsToDisabled() throws Exception {
UUID playerId = UUID.randomUUID();