feat(base): identify nearby bases with private owner labels
Release / release (push) Successful in 7m34s
CI / build (push) Successful in 2m47s

This commit is contained in:
dmg
2026-09-12 11:42:37 -04:00
parent 8fc4075276
commit 0768a63038
14 changed files with 1045 additions and 1 deletions
@@ -0,0 +1,90 @@
package games.dmg.spigotbase;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/** Server-thread model: actual base bounds, stable anchors and per-visit notifications; no Bukkit or I/O. */
public final class BaseIdentification {
public record Base(UUID owner, String ownerName, BaseArea area) { }
public record Viewer(UUID id, UUID world, int x, int y, int z) { }
public record Label(UUID owner, String text, UUID world, double x, double y, double z, float viewRange) { }
public record Frame(Map<UUID, Label> labels, List<String> entries) { }
private record Origin(UUID world, int x, int y, int z) {
static Origin of(BaseLocation center) {
return new Origin(center.worldId(), center.x(), center.y(), center.z());
}
}
private record Prepared(Base base, Origin origin, String ownerName, Label label) { }
private record Visit(Origin origin, boolean announced) { }
private List<Base> bases = List.of();
private Map<UUID, List<Prepared>> byWorld = Map.of();
private final Map<UUID, Map<UUID, Visit>> visits = new HashMap<>();
public void setBases(Collection<Base> next) {
List<Base> sorted = next.stream().sorted(Comparator.comparing(base -> base.owner().toString())).toList();
if (sorted.equals(bases)) {
return;
}
Map<Origin, Integer> ranks = new HashMap<>();
Map<UUID, List<Prepared>> prepared = new HashMap<>();
for (Base base : sorted) {
BaseArea area = base.area();
BaseLocation center = area.center();
Origin origin = Origin.of(center);
int rank = ranks.merge(origin, 1, Integer::sum) - 1;
double offset = 2.25 + rank * 0.35;
String name = base.ownerName() == null || base.ownerName().isBlank() ? base.owner().toString() : base.ownerName();
float viewRange = (float) Math.max(16, Math.hypot((double) area.radius() + 16,
(double) area.verticalRange() + offset + 1) / 64);
Label label = new Label(base.owner(), "Base: " + name, center.worldId(),
center.x() + 0.5, center.y() + offset, center.z() + 0.5, viewRange);
prepared.computeIfAbsent(center.worldId(), ignored -> new ArrayList<>())
.add(new Prepared(base, origin, name, label));
}
prepared.replaceAll((world, values) -> List.copyOf(values));
bases = sorted;
byWorld = Map.copyOf(prepared);
}
public void forget(UUID viewer) { visits.remove(viewer); }
public void clear() { visits.clear(); bases = List.of(); byWorld = Map.of(); }
public Frame update(Viewer viewer) {
Map<UUID, Visit> previous = visits.getOrDefault(viewer.id(), Map.of());
Map<UUID, Visit> retained = new HashMap<>();
Map<UUID, Label> visible = new LinkedHashMap<>();
List<String> entries = new ArrayList<>();
for (Prepared prepared : byWorld.getOrDefault(viewer.world(), List.of())) {
Base base = prepared.base();
BaseArea area = base.area();
BaseLocation center = area.center();
if (base.owner().equals(viewer.id())) {
continue;
}
double distance = Math.hypot((double) viewer.x() - center.x(), (double) viewer.z() - center.z());
long vertical = Math.abs((long) viewer.y() - center.y());
if (distance > (double) area.radius() + 16 || vertical > (long) area.verticalRange() + 16) {
continue;
}
Visit old = previous.get(base.owner());
boolean announced = old != null && old.origin().equals(prepared.origin()) && old.announced();
if (!announced && area.contains(viewer.world(), viewer.x(), viewer.y(), viewer.z())) {
entries.add("Entering " + prepared.ownerName() + "'s base");
announced = true;
}
retained.put(base.owner(), new Visit(prepared.origin(), announced));
if (vertical <= area.verticalRange()) {
visible.put(base.owner(), prepared.label());
}
}
if (retained.isEmpty()) { visits.remove(viewer.id()); }
else { visits.put(viewer.id(), retained); }
return new Frame(Map.copyOf(visible), List.copyOf(entries));
}
}
@@ -0,0 +1,121 @@
package games.dmg.spigotbase;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import net.kyori.adventure.text.Component;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
/** Read-only state projection. Polling catches administration/settings changes; moves catch entries between polls. */
final class BaseIdentificationController implements Runnable, Listener, AutoCloseable {
private final Server server;
private final BaseStateManager states;
private final BaseBoundsService bounds;
private final BukkitBaseLabels labels;
private final Logger logger;
private final BaseIdentification identification = new BaseIdentification();
private final Set<UUID> tracked = new HashSet<>();
private final Set<UUID> failed = new HashSet<>();
private boolean closed;
BaseIdentificationController(Server server, BaseStateManager states, BaseBoundsService bounds,
BukkitBaseLabels labels, Logger logger) {
this.server = server;
this.states = states;
this.bounds = bounds;
this.labels = labels;
this.logger = logger;
}
@Override
public void run() {
if (closed) { return; }
identification.setBases(states.knownPlayers().values().stream().filter(state -> state.base().isPresent())
.map(state -> new BaseIdentification.Base(state.playerId(), state.latestName(), bounds.area(state))).toList());
Set<UUID> online = new HashSet<>();
for (Player player : server.getOnlinePlayers()) {
online.add(player.getUniqueId());
update(player, player.getLocation());
}
for (UUID id : Set.copyOf(tracked)) {
if (!online.contains(id)) { forget(id); }
}
}
private void update(Player player, Location location) {
if (closed || !player.isOnline() || location == null || location.getWorld() == null
|| !location.getWorld().equals(player.getWorld())) { return; }
UUID id = player.getUniqueId();
tracked.add(id);
var frame = identification.update(new BaseIdentification.Viewer(id, location.getWorld().getUID(),
location.getBlockX(), location.getBlockY(), location.getBlockZ()));
if (!frame.entries().isEmpty()) {
player.sendActionBar(Component.text(String.join("", frame.entries())));
}
try {
labels.render(player, frame.labels());
failed.remove(id);
} catch (RuntimeException exception) {
// Retained partial packet delivery is retried on the next update; avoid repeated log spam.
if (failed.add(id)) { logger.log(Level.WARNING, "Could not update private base labels for " + id, exception); }
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onMove(PlayerMoveEvent event) {
if (event.isCancelled() || event.getTo() == null) { return; }
Location from = event.getFrom(), to = event.getTo();
if (from.getWorld().equals(to.getWorld()) && (from.getBlockX() != to.getBlockX()
|| from.getBlockY() != to.getBlockY() || from.getBlockZ() != to.getBlockZ())) {
update(event.getPlayer(), to);
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onTeleport(org.bukkit.event.player.PlayerTeleportEvent event) { onMove(event); }
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) { forget(event.getPlayer().getUniqueId()); }
@EventHandler(priority = EventPriority.MONITOR)
public void onWorldChange(PlayerChangedWorldEvent event) { forget(event.getPlayer().getUniqueId()); }
@EventHandler(priority = EventPriority.MONITOR)
public void onRespawn(PlayerRespawnEvent event) {
try { labels.clear(event.getPlayer()); }
catch (RuntimeException exception) { logger.log(Level.WARNING, "Could not clear respawning player's base labels", exception); }
forget(event.getPlayer().getUniqueId());
}
private void forget(UUID id) {
labels.forget(id);
identification.forget(id);
tracked.remove(id);
failed.remove(id);
}
@Override
public void close() {
if (closed) { return; }
closed = true;
for (Player player : server.getOnlinePlayers()) {
try { labels.clear(player); }
catch (RuntimeException exception) { logger.log(Level.WARNING, "Could not remove private base labels on disable", exception); }
}
labels.forgetAll();
identification.clear();
tracked.clear();
failed.clear();
}
}
@@ -0,0 +1,63 @@
package games.dmg.spigotbase;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.entity.TextDisplay;
/** Viewer-private, unspawned displays. All access is confined to the server thread. */
final class BukkitBaseLabels {
interface Packets {
int nextId(World world);
List<Object> spawn(TextDisplay display, BaseIdentification.Label label, int entityId);
Object remove(int entityId);
void send(Player viewer, Object packet);
}
private record Shown(BaseIdentification.Label label, int id, boolean complete) { }
private final Packets packets;
private final Map<UUID, Map<UUID, Shown>> viewers = new java.util.HashMap<>();
BukkitBaseLabels(Packets packets) { this.packets = packets; }
void render(Player viewer, Map<UUID, BaseIdentification.Label> labels) {
World world = viewer.getWorld();
UUID worldId = world.getUID();
Map<UUID, Shown> shown = viewers.computeIfAbsent(viewer.getUniqueId(), ignored -> new java.util.HashMap<>());
var iterator = shown.entrySet().iterator();
while (iterator.hasNext()) {
var entry = iterator.next();
Shown old = entry.getValue();
if (!old.label().world().equals(worldId)) {
// A dimension change already cleared the client world. Never destroy old IDs in a new world.
iterator.remove();
} else if (!old.complete() || !old.label().equals(labels.get(entry.getKey()))) {
packets.send(viewer, packets.remove(old.id()));
iterator.remove();
}
}
for (var label : labels.values()) {
if (!label.world().equals(worldId) || shown.containsKey(label.owner())) { continue; }
// createEntity does not spawn/register the entity or load its anchor chunk.
TextDisplay display = world.createEntity(new org.bukkit.Location(world, label.x(), label.y(), label.z()), TextDisplay.class);
display.setPersistent(false);
display.text(net.kyori.adventure.text.Component.text(label.text()));
display.setBillboard(org.bukkit.entity.Display.Billboard.CENTER);
display.setViewRange(label.viewRange());
display.setShadowed(true);
display.setLineWidth(512); // Keep UUID fallback names on one line at stacked anchors.
int id = packets.nextId(world);
List<Object> spawn = packets.spawn(display, label, id);
// Retain partial delivery so the next render/clear can remove it before retrying.
shown.put(label.owner(), new Shown(label, id, false));
for (Object packet : spawn) { packets.send(viewer, packet); }
shown.put(label.owner(), new Shown(label, id, true));
}
if (shown.isEmpty()) { viewers.remove(viewer.getUniqueId()); }
}
void clear(Player viewer) { render(viewer, Map.of()); }
void forget(UUID viewer) { viewers.remove(viewer); }
void forgetAll() { viewers.clear(); }
}
@@ -0,0 +1,88 @@
package games.dmg.spigotbase;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.List;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.entity.TextDisplay;
/** Declared-runtime packet boundary; no reflection targets or packet types come from player input. */
final class NativeBaseLabelPackets implements BukkitBaseLabels.Packets {
private final Constructor<?> addEntity;
private final Constructor<?> metadata;
private final Constructor<?> removeEntities;
private final Object zeroVelocity;
private final Method entityHandle;
private final Method entityType;
private final Method entityData;
private final Method nonDefaultValues;
private final Method playerHandle;
private final Field connection;
private final Method sendPacket;
NativeBaseLabelPackets() {
try {
Class<?> entity = Class.forName("net.minecraft.world.entity.Entity");
Class<?> type = Class.forName("net.minecraft.world.entity.EntityType");
Class<?> vector = Class.forName("net.minecraft.world.phys.Vec3");
Class<?> packet = Class.forName("net.minecraft.network.protocol.Packet");
entityHandle = Class.forName("org.bukkit.craftbukkit.entity.CraftEntity").getMethod("getHandle");
entityType = entity.getMethod("getType");
entityData = entity.getMethod("getEntityData");
nonDefaultValues = Class.forName("net.minecraft.network.syncher.SynchedEntityData").getMethod("getNonDefaultValues");
zeroVelocity = vector.getConstructor(double.class, double.class, double.class).newInstance(0.0, 0.0, 0.0);
addEntity = Class.forName("net.minecraft.network.protocol.game.ClientboundAddEntityPacket").getConstructor(
int.class, UUID.class, double.class, double.class, double.class, float.class, float.class,
type, int.class, vector, double.class);
metadata = Class.forName("net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket")
.getConstructor(int.class, List.class);
removeEntities = Class.forName("net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket")
.getConstructor(int[].class);
playerHandle = Class.forName("org.bukkit.craftbukkit.entity.CraftPlayer").getMethod("getHandle");
connection = Class.forName("net.minecraft.server.level.ServerPlayer").getField("connection");
sendPacket = Class.forName("net.minecraft.server.network.ServerCommonPacketListenerImpl").getMethod("send", packet);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Client-only base labels are unsupported on this server version", exception);
}
}
@Override
public int nextId(org.bukkit.World world) {
return org.bukkit.Bukkit.getUnsafe().nextEntityId(world);
}
@Override
public List<Object> spawn(TextDisplay entity, BaseIdentification.Label label, int entityId) {
try {
Object handle = entityHandle.invoke(entity);
Object values = nonDefaultValues.invoke(entityData.invoke(handle));
// Construct both packets before sending either, so an unsupported layout cannot leave a half-created label.
Object spawn = addEntity.newInstance(entityId, entity.getUniqueId(), label.x(), label.y(), label.z(),
0.0f, 0.0f, entityType.invoke(handle), 0, zeroVelocity, 0.0);
Object data = metadata.newInstance(entityId, values == null ? List.of() : values);
return List.of(spawn, data);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not encode a client-only base label", exception);
}
}
@Override
public Object remove(int entityId) {
try {
return removeEntities.newInstance((Object) new int[] {entityId});
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not encode base label removal", exception);
}
}
@Override
public void send(Player viewer, Object packet) {
try {
sendPacket.invoke(connection.get(playerHandle.invoke(viewer)), packet);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not send a private base label packet", exception);
}
}
}
@@ -18,6 +18,7 @@ public final class SpigotBasePlugin extends JavaPlugin {
private PocketBaseManager pocketBaseManager;
private PocketBaseController pocketBaseController;
private PocketBaseKeystoneService keystoneService;
private BaseIdentificationController identificationController;
@Override
public void onEnable() {
@@ -66,6 +67,14 @@ public final class SpigotBasePlugin extends JavaPlugin {
TeleportProgressionService teleportProgressionService =
new TeleportProgressionService(settingsProvider);
BaseBoundsService boundsService = new BaseBoundsService(settingsProvider);
try {
identificationController = new BaseIdentificationController(getServer(), stateManager, boundsService,
new BukkitBaseLabels(new NativeBaseLabelPackets()), getLogger());
getServer().getPluginManager().registerEvents(identificationController, this);
getServer().getScheduler().runTaskTimer(this, identificationController, 5L, 5L);
} catch (RuntimeException | LinkageError exception) {
getLogger().log(Level.SEVERE, "Nearby base identification is unavailable on this server; other base features remain enabled", exception);
}
progressListener = new BaseProgressListener(
this,
stateManager,
@@ -156,6 +165,9 @@ public final class SpigotBasePlugin extends JavaPlugin {
@Override
public void onDisable() {
if (identificationController != null) {
identificationController.close();
}
if (progressListener != null) {
progressListener.removeAllBossBars();
}
@@ -0,0 +1,153 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import io.netty.buffer.Unpooled;
import java.util.UUID;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.protocol.game.ClientboundAddEntityPacket;
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.EntityTypes;
import org.bukkit.craftbukkit.CraftRegistry;
import org.bukkit.craftbukkit.CraftServer;
import org.bukkit.craftbukkit.entity.CraftTextDisplay;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class NativeBaseLabelPacketsTest {
@BeforeAll
static void bootstrap() throws Exception {
NativeRuntime.bootstrap();
// Metadata encoding enters the server's item-obfuscation context even for text-only packets.
var global = mock(io.papermc.paper.configuration.GlobalConfiguration.class);
global.anticheat = mock(io.papermc.paper.configuration.GlobalConfiguration.Anticheat.class);
global.anticheat.obfuscation = mock(io.papermc.paper.configuration.GlobalConfiguration.Anticheat.Obfuscation.class);
global.anticheat.obfuscation.items = mock(io.papermc.paper.configuration.GlobalConfiguration.Anticheat.Obfuscation.Items.class);
var binding = mock(io.papermc.paper.util.sanitizer.ItemObfuscationBinding.class);
var level = io.papermc.paper.util.sanitizer.ItemObfuscationBinding.class.getField("level");
level.setAccessible(true);
level.set(binding, io.papermc.paper.util.sanitizer.ItemObfuscationSession.ObfuscationLevel.NONE);
global.anticheat.obfuscation.items.binding = binding;
var set = io.papermc.paper.configuration.GlobalConfiguration.class.getDeclaredMethod("set",
io.papermc.paper.configuration.GlobalConfiguration.class);
set.setAccessible(true);
set.invoke(null, global);
}
@Test
void removalRoundTripsAndDeliveryUsesOnlyTheSelectedPlayerConnection() throws Exception {
var packets = new NativeBaseLabelPackets();
var remove = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket.class,
packets.remove(42));
var buffer = new net.minecraft.network.FriendlyByteBuf(Unpooled.buffer());
try {
net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket.STREAM_CODEC.encode(buffer, remove);
var decoded = net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket.STREAM_CODEC.decode(buffer);
assertEquals(java.util.List.of(42), decoded.getEntityIds());
} finally { buffer.release(); }
var player = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class);
var handle = mock(net.minecraft.server.level.ServerPlayer.class);
handle.connection = mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class);
when(player.getHandle()).thenReturn(handle);
packets.send(player, remove);
verify(handle.connection).send(remove);
verifyNoMoreInteractions(handle.connection);
}
private static ServerLevel nativeLevel() throws Exception {
var level = mock(ServerLevel.class);
var configuration = mock(org.purpurmc.purpur.PurpurWorldConfig.class);
configuration.drowningAirTicks = 300;
var field = net.minecraft.world.level.Level.class.getField("purpurConfig");
field.setAccessible(true);
field.set(level, configuration);
var spigot = mock(org.spigotmc.SpigotWorldConfig.class);
spigot.miscActivationRange = 32;
var spigotField = net.minecraft.world.level.Level.class.getField("spigotConfig");
spigotField.setAccessible(true);
spigotField.set(level, spigot);
var paper = mock(io.papermc.paper.configuration.WorldConfiguration.class);
paper.entities = mock(io.papermc.paper.configuration.WorldConfiguration.Entities.class);
paper.entities.spawning = mock(io.papermc.paper.configuration.WorldConfiguration.Entities.Spawning.class);
paper.entities.spawning.despawnTime = new it.unimi.dsi.fastutil.objects.Reference2ObjectOpenHashMap<>();
when(level.paperConfig()).thenReturn(paper);
return level;
}
@Test
void publicCreationAndIdReservationDoNotRegisterEntitiesOrLoadAnchorChunks() throws Exception {
var level = nativeLevel();
var world = mock(org.bukkit.craftbukkit.CraftWorld.class);
when(world.getHandle()).thenReturn(level);
when(level.getLevel()).thenReturn(level);
when(level.getMinecraftWorld()).thenReturn(level);
when(level.enabledFeatures()).thenReturn(net.minecraft.world.flag.FeatureFlags.VANILLA_SET);
when(world.isNormalWorld()).thenReturn(true);
when(world.isEnabled(any(io.papermc.paper.world.flag.FeatureDependant.class))).thenReturn(true);
when(level.getCraftServer()).thenReturn(mock(CraftServer.class));
when(level.getWorld()).thenReturn(world);
doCallRealMethod().when(world).createEntity(any(org.bukkit.Location.class), eq(org.bukkit.entity.TextDisplay.class));
doCallRealMethod().when(world).createEntity(any(org.bukkit.Location.class), eq(org.bukkit.entity.TextDisplay.class), eq(true));
var display = world.createEntity(new org.bukkit.Location(world, 100.5, 66.25, -99.5), org.bukkit.entity.TextDisplay.class);
assertInstanceOf(CraftTextDisplay.class, display);
assertThrows(IllegalStateException.class, display::getEntityId);
var chunkSource = mock(net.minecraft.server.level.ServerChunkCache.class);
var field = ServerLevel.class.getDeclaredField("chunkSource");
field.setAccessible(true);
field.set(level, chunkSource);
when(level.getNextEntityId()).thenCallRealMethod();
try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) {
bukkit.when(org.bukkit.Bukkit::getUnsafe).thenReturn(org.bukkit.craftbukkit.util.CraftMagicNumbers.INSTANCE);
var packets = new NativeBaseLabelPackets();
int first = packets.nextId(world), second = packets.nextId(world);
assertNotEquals(0, first);
assertNotEquals(first, second);
}
verify(chunkSource, atLeast(2)).hasEntityWithId(anyInt());
verifyNoMoreInteractions(chunkSource);
assertTrue(mockingDetails(level).getInvocations().stream().noneMatch(call ->
call.getMethod().getName().matches("(getChunk.*|loadChunk.*|add.*Entity.*)")),
"Public creation and ID allocation must not load chunks or add entities");
}
@Test
void nativeSpawnAndMetadataRoundTripCarryCorrectTypeCoordinatesAndLiteralText() throws Exception {
var level = nativeLevel();
var nativeDisplay = new net.minecraft.world.entity.Display.TextDisplay(EntityTypes.TEXT_DISPLAY, level);
var display = new CraftTextDisplay(mock(CraftServer.class), nativeDisplay);
display.text(net.kyori.adventure.text.Component.text("Base: Alice"));
display.setBillboard(org.bukkit.entity.Display.Billboard.CENTER);
display.setViewRange(16);
display.setPersistent(false);
var description = new BaseIdentification.Label(UUID.randomUUID(), "Base: Alice", UUID.randomUUID(),
100.5, 66.25, -99.5, 16);
var packets = new NativeBaseLabelPackets().spawn(display, description, 42);
assertEquals(2, packets.size(), "A label needs native spawn and complete metadata packets");
var spawn = assertInstanceOf(ClientboundAddEntityPacket.class, packets.get(0));
var metadata = assertInstanceOf(ClientboundSetEntityDataPacket.class, packets.get(1));
var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), CraftRegistry.getMinecraftRegistry());
try {
ClientboundAddEntityPacket.STREAM_CODEC.encode(buffer, spawn);
var decoded = ClientboundAddEntityPacket.STREAM_CODEC.decode(buffer);
assertEquals(EntityTypes.TEXT_DISPLAY, decoded.getType());
assertEquals(42, decoded.getId());
assertEquals(display.getUniqueId(), decoded.getUUID());
assertEquals(100.5, decoded.getX());
assertEquals(66.25, decoded.getY());
assertEquals(-99.5, decoded.getZ());
ClientboundSetEntityDataPacket.STREAM_CODEC.encode(buffer, metadata);
var decodedMetadata = ClientboundSetEntityDataPacket.STREAM_CODEC.decode(buffer);
assertEquals(42, decodedMetadata.id());
assertTrue(decodedMetadata.packedItems().stream().anyMatch(value ->
value.value() instanceof net.minecraft.network.chat.Component component
&& component.getString().equals("Base: Alice")));
} finally {
buffer.release();
}
verify(level, never()).addFreshEntity(nativeDisplay);
assertThrows(IllegalStateException.class, nativeDisplay::getId, "The entity remains unassigned/unspawned; only the packet has a reserved ID");
assertFalse(display.isPersistent());
}
}
@@ -0,0 +1,58 @@
package games.dmg.spigotbase;
import java.util.List;
import java.util.stream.Stream;
import net.minecraft.SharedConstants;
import net.minecraft.commands.Commands;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.LayeredRegistryAccess;
import net.minecraft.core.Registry;
import net.minecraft.core.RegistryAccess;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.RegistryDataLoader;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.RegistryLayer;
import net.minecraft.server.ReloadableServerResources;
import net.minecraft.server.packs.PackType;
import net.minecraft.server.packs.repository.ServerPacksSource;
import net.minecraft.server.packs.resources.MultiPackResourceManager;
import net.minecraft.server.permissions.LevelBasedPermissionSet;
import net.minecraft.tags.TagLoader;
import net.minecraft.util.Util;
import net.minecraft.world.flag.FeatureFlags;
import net.minecraft.world.level.DataPackConfig;
import net.minecraft.world.level.WorldDataConfiguration;
import org.bukkit.craftbukkit.CraftRegistry;
/** Loads vanilla registries/tags/components using the same path as the server's own tests. */
final class NativeRuntime {
private NativeRuntime() {}
static void bootstrap() throws Exception {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
var flags = FeatureFlags.VANILLA_SET;
var packs = ServerPacksSource.createVanillaTrustedRepository();
MinecraftServer.configurePackRepository(packs, new WorldDataConfiguration(new DataPackConfig(
FeatureFlags.REGISTRY.toNames(flags).stream().map(Identifier::getPath).toList(), List.of()), flags), true, false);
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
Class.forName(org.bukkit.Registry.class.getName());
var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags,
Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS,
Util.backgroundExecutor(), Runnable::run).join();
datapack.updateComponentsAndStaticRegistryTags();
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
}
}
}
@@ -0,0 +1 @@
mock-maker-inline
@@ -0,0 +1,157 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.logging.Logger;
import net.kyori.adventure.text.Component;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
class BaseIdentificationControllerTest {
private final UUID owner = UUID.randomUUID(), visitor = UUID.randomUUID(), worldId = UUID.randomUUID();
private final Server server = mock(Server.class);
private final World world = mock(World.class);
private final Player player = mock(Player.class);
private final BaseStateManager states = mock(BaseStateManager.class);
private final BukkitBaseLabels labels = mock(BukkitBaseLabels.class);
private final PlayerState initial = PlayerState.newPlayer(owner, "Alice")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(new BaseLocation(worldId, "world", 100, 64, -100, 0, 0), Instant.EPOCH);
private final BaseIdentificationController controller = new BaseIdentificationController(server, states,
new BaseBoundsService(PluginSettings.from(Map.of())), labels, Logger.getAnonymousLogger());
BaseIdentificationControllerTest() {
when(world.getUID()).thenReturn(worldId);
when(player.getUniqueId()).thenReturn(visitor);
when(player.getWorld()).thenReturn(world);
when(player.isOnline()).thenReturn(true);
when(player.getLocation()).thenReturn(at(100, 64));
doReturn(List.of(player)).when(server).getOnlinePlayers();
when(states.knownPlayers()).thenReturn(Map.of(owner, initial));
}
@Test
@SuppressWarnings("unchecked")
void readsOfflineOwnersWithoutPermissionsOrStateMutationAndSendsOnePrivateEntry() {
controller.run();
controller.run();
var frames = ArgumentCaptor.forClass(Map.class);
verify(labels, times(2)).render(eq(player), frames.capture());
var frame = (Map<UUID, BaseIdentification.Label>) frames.getValue();
assertEquals("Base: Alice", frame.get(owner).text());
assertEquals(100.5, frame.get(owner).x());
verify(player).sendActionBar(Component.text("Entering Alice's base"));
verify(player, never()).sendMessage(anyString());
verify(player, never()).hasPermission(anyString());
verify(states, times(2)).knownPlayers();
verifyNoMoreInteractions(states);
verify(server, times(2)).getOnlinePlayers();
verifyNoMoreInteractions(server); // No offline profile, disk, world or chunk lookup.
}
@Test
@SuppressWarnings("unchecked")
void pollsUpdatedExpansionsRelocationsResetsAndKeepsTheOwnerExcluded() {
when(player.getLocation()).thenReturn(at(200, 64));
controller.run();
verify(labels).render(player, Map.of());
var expanded = initial.withAdministrativeLevels(1, 3, 0, 0, 0, false, false, false);
when(states.knownPlayers()).thenReturn(Map.of(owner, expanded));
controller.run();
var frames = ArgumentCaptor.forClass(Map.class);
verify(labels, times(2)).render(eq(player), frames.capture());
assertTrue(frames.getValue().containsKey(owner));
var moved = expanded.withBase(new BaseLocation(worldId, "world", 500, 64, -100, 0, 0), Instant.EPOCH);
when(states.knownPlayers()).thenReturn(Map.of(owner, moved));
controller.run();
verify(labels, times(2)).render(player, Map.of());
when(states.knownPlayers()).thenReturn(Map.of());
controller.run();
verify(labels, times(3)).render(player, Map.of());
when(states.knownPlayers()).thenReturn(Map.of(owner, initial));
when(player.getUniqueId()).thenReturn(owner);
when(player.getLocation()).thenReturn(at(100, 64));
controller.run();
verify(labels, times(4)).render(player, Map.of());
}
@Test
void moveRoutingIgnoresCancellationAndEntryJitterButCatchesEntryBetweenPolls() {
when(player.getLocation()).thenReturn(at(126, 64));
controller.run();
var cancelled = new PlayerMoveEvent(player, at(126, 64), at(100, 64));
cancelled.setCancelled(true);
controller.onMove(cancelled);
verify(player, never()).sendActionBar(any(Component.class));
controller.onMove(new PlayerMoveEvent(player, at(126, 64), at(100, 64)));
controller.onMove(new PlayerMoveEvent(player, at(100, 64), at(111, 64)));
controller.onMove(new PlayerMoveEvent(player, at(111, 64), at(100, 64)));
verify(player).sendActionBar(Component.text("Entering Alice's base"));
}
@Test
void disconnectWorldChangeAndDisableCleanUpAndReleaseVisitSuppression() {
controller.run();
controller.onQuit(new PlayerQuitEvent(player, Component.empty()));
verify(labels).forget(visitor);
controller.run();
verify(player, times(2)).sendActionBar(Component.text("Entering Alice's base"));
controller.onWorldChange(new PlayerChangedWorldEvent(player, world));
verify(labels, times(2)).forget(visitor);
controller.run();
verify(player, times(3)).sendActionBar(Component.text("Entering Alice's base"));
controller.close();
verify(labels).clear(player);
verify(labels).forgetAll();
clearInvocations(labels, player, states, server);
controller.run();
verifyNoInteractions(labels, player, states, server);
}
@Test
void teleportHasItsOwnBukkitHandlerAndNotifiesOnSameWorldEntry() throws Exception {
when(player.getLocation()).thenReturn(at(300, 64));
controller.run();
var teleport = new org.bukkit.event.player.PlayerTeleportEvent(player, at(300, 64), at(100, 64));
teleport.setCancelled(true);
controller.onTeleport(teleport);
verify(player, never()).sendActionBar(any(Component.class));
teleport.setCancelled(false);
controller.onTeleport(teleport);
verify(player).sendActionBar(Component.text("Entering Alice's base"));
var handler = BaseIdentificationController.class.getMethod("onTeleport",
org.bukkit.event.player.PlayerTeleportEvent.class).getAnnotation(org.bukkit.event.EventHandler.class);
assertNotNull(handler, "Teleport has a separate HandlerList from ordinary movement");
assertTrue(handler.ignoreCancelled());
assertEquals(org.bukkit.event.EventPriority.MONITOR, handler.priority());
}
@Test
void respawnAndMissingQuitEventsCannotRetainLabelsOrVisitSessions() {
controller.run();
var respawn = mock(org.bukkit.event.player.PlayerRespawnEvent.class);
when(respawn.getPlayer()).thenReturn(player);
controller.onRespawn(respawn);
verify(labels).clear(player);
verify(labels).forget(visitor);
controller.run();
verify(player, times(2)).sendActionBar(Component.text("Entering Alice's base"));
doReturn(List.of()).when(server).getOnlinePlayers();
controller.run();
verify(labels, times(2)).forget(visitor);
}
private Location at(int x, int y) { return new Location(world, x, y, -100); }
}
@@ -0,0 +1,75 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class BaseIdentificationTest {
private final UUID world = UUID.randomUUID(), owner = UUID.randomUUID(), visitor = UUID.randomUUID();
private final BaseLocation center = new BaseLocation(world, "world", 100, 64, -100, 0, 0);
@Test
void labelUsesActualCircularBoundsAndVerticalLimitsButStaysAtRecordedCenter() {
var identification = new BaseIdentification();
identification.setBases(List.of(new BaseIdentification.Base(owner, "Alice", new BaseArea(center, 10, 25))));
assertTrue(identification.update(viewer(owner, 100, 64, -100)).labels().isEmpty(), "No own-base label");
var near = identification.update(viewer(visitor, 126, 64, -100));
assertEquals(1, near.labels().size(), "Exactly 16 blocks beyond the boundary is visible");
var label = near.labels().get(owner);
assertEquals("Base: Alice", label.text());
assertEquals(world, label.world());
assertEquals(100.5, label.x());
assertEquals(66.25, label.y());
assertEquals(-99.5, label.z());
assertEquals(label, identification.update(viewer(visitor, 100, 64, -100)).labels().get(owner),
"Moving the visitor cannot move the label anchor");
assertTrue(identification.update(viewer(visitor, 127, 64, -100)).labels().isEmpty());
assertTrue(identification.update(viewer(visitor, 126, 64, -74)).labels().isEmpty(), "Circular, not square");
assertFalse(identification.update(viewer(visitor, 100, 89, -100)).labels().isEmpty());
assertTrue(identification.update(viewer(visitor, 100, 90, -100)).labels().isEmpty());
assertTrue(identification.update(new BaseIdentification.Viewer(visitor, UUID.randomUUID(), 100, 64, -100)).labels().isEmpty());
}
@Test
void entryNotificationsRearmOnlyAfterLeavingTheVicinity() {
var identification = new BaseIdentification();
identification.setBases(List.of(new BaseIdentification.Base(owner, "Alice", new BaseArea(center, 10, 25))));
assertTrue(identification.update(viewer(visitor, 115, 64, -100)).entries().isEmpty());
assertEquals(List.of("Entering Alice's base"), identification.update(viewer(visitor, 110, 64, -100)).entries());
assertTrue(identification.update(viewer(visitor, 100, 64, -100)).entries().isEmpty());
identification.update(viewer(visitor, 111, 64, -100));
assertTrue(identification.update(viewer(visitor, 110, 64, -100)).entries().isEmpty());
identification.update(viewer(visitor, 100, 90, -100));
assertTrue(identification.update(viewer(visitor, 100, 89, -100)).entries().isEmpty(), "Vertical edge jitter also stays quiet");
identification.update(viewer(visitor, 127, 64, -100));
assertEquals(List.of("Entering Alice's base"), identification.update(viewer(visitor, 100, 64, -100)).entries());
identification.forget(visitor);
assertEquals(List.of("Entering Alice's base"), identification.update(viewer(visitor, 100, 64, -100)).entries());
}
@Test
void coincidentOwnersHaveDistinctStableLabelsAndExpansionUsesCurrentBounds() {
UUID other = UUID.randomUUID();
var identification = new BaseIdentification();
var first = new BaseIdentification.Base(owner, "Alice", new BaseArea(center, 10, 25));
var second = new BaseIdentification.Base(other, "Bob", new BaseArea(center, 10, 25));
identification.setBases(List.of(first, second));
var frame = identification.update(viewer(visitor, 100, 64, -100));
assertEquals(2, frame.labels().size());
assertNotEquals(frame.labels().get(owner).y(), frame.labels().get(other).y());
assertEquals(100.5, frame.labels().get(other).x());
identification.setBases(List.of(second, first));
assertEquals(frame.labels(), identification.update(viewer(visitor, 100, 64, -100)).labels(), "Input iteration order cannot move labels");
identification.setBases(List.of(new BaseIdentification.Base(owner, "Alice", new BaseArea(center, 150, Integer.MAX_VALUE))));
assertFalse(identification.update(viewer(visitor, 266, -64, -100)).labels().isEmpty());
assertTrue(identification.update(viewer(visitor, 267, -64, -100)).labels().isEmpty());
identification.setBases(List.of());
assertTrue(identification.update(viewer(visitor, 100, 64, -100)).labels().isEmpty());
}
private BaseIdentification.Viewer viewer(UUID id, int x, int y, int z) {
return new BaseIdentification.Viewer(id, world, x, y, z);
}
}
@@ -0,0 +1,58 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.List;
import java.util.logging.Logger;
import org.bukkit.Server;
import org.bukkit.command.PluginCommand;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.plugin.PluginManager;
import org.bukkit.scheduler.BukkitScheduler;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
class BaseIdentificationWiringTest {
@TempDir Path directory;
@Test
void enableRegistersAndSchedulesIdentificationAndDisableClosesIt() {
var plugin = mock(SpigotBasePlugin.class);
var server = mock(Server.class);
var manager = mock(PluginManager.class);
var scheduler = mock(BukkitScheduler.class);
when(plugin.getServer()).thenReturn(server);
when(plugin.getName()).thenReturn("SpigotBase");
when(plugin.namespace()).thenReturn("spigotbase");
when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
when(plugin.getConfig()).thenReturn(new YamlConfiguration());
when(plugin.getDataFolder()).thenReturn(directory.toFile());
when(plugin.getCommand(anyString())).thenReturn(mock(PluginCommand.class));
when(server.getPluginManager()).thenReturn(manager);
when(server.getScheduler()).thenReturn(scheduler);
doReturn(List.of()).when(server).getOnlinePlayers();
doCallRealMethod().when(plugin).onEnable();
doCallRealMethod().when(plugin).onDisable();
try (var validator = mockStatic(PluginSettingsValidator.class);
var keystones = mockConstruction(PocketBaseKeystoneService.class);
var packets = mockConstruction(NativeBaseLabelPackets.class);
var labels = mockConstruction(BukkitBaseLabels.class)) {
validator.when(() -> PluginSettingsValidator.validateMaterials(any())).thenAnswer(call -> call.getArgument(0));
plugin.onEnable();
assertEquals(1, keystones.constructed().size());
verify(manager, never()).disablePlugin(plugin);
var tasks = ArgumentCaptor.forClass(Runnable.class);
verify(scheduler, atLeastOnce()).runTaskTimer(eq(plugin), tasks.capture(), anyLong(), anyLong());
var identification = tasks.getAllValues().stream().filter(BaseIdentificationController.class::isInstance).toList();
assertEquals(1, identification.size(), "Enable must install the actual label controller");
verify(manager).registerEvents((BaseIdentificationController) identification.getFirst(), plugin);
identification.getFirst().run();
plugin.onDisable();
assertEquals(1, packets.constructed().size());
verify(labels.constructed().getFirst()).forgetAll();
}
}
}
@@ -0,0 +1,108 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import net.kyori.adventure.text.Component;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Display;
import org.bukkit.entity.Player;
import org.bukkit.entity.TextDisplay;
import org.junit.jupiter.api.Test;
class BukkitBaseLabelsTest {
private final World world = mock(World.class);
private final Player viewer = mock(Player.class);
private final TextDisplay display = mock(TextDisplay.class);
private final BukkitBaseLabels.Packets packets = mock(BukkitBaseLabels.Packets.class);
private final UUID owner = UUID.randomUUID();
private final BaseIdentification.Label label;
private final Object spawn = new Object(), metadata = new Object(), remove = new Object();
private final BukkitBaseLabels labels = new BukkitBaseLabels(packets);
BukkitBaseLabelsTest() {
UUID worldId = UUID.randomUUID();
when(world.getUID()).thenReturn(worldId);
when(viewer.getUniqueId()).thenReturn(UUID.randomUUID());
when(viewer.getWorld()).thenReturn(world);
label = new BaseIdentification.Label(owner, "Base: Alice", worldId, 100.5, 66.25, -99.5, 16);
when(world.createEntity(any(Location.class), eq(TextDisplay.class))).thenReturn(display);
when(packets.nextId(world)).thenReturn(42, 43, 44);
when(packets.spawn(eq(display), any(), anyInt())).thenReturn(List.of(spawn, metadata));
when(packets.remove(anyInt())).thenReturn(remove);
}
@Test
void createsOnlyPrivateUnspawnedDisplaysAtTheAnchorAndDoesNotRepeatStableFrames() {
labels.render(viewer, Map.of(owner, label));
labels.render(viewer, Map.of(owner, label));
verify(world).createEntity(new Location(world, 100.5, 66.25, -99.5), TextDisplay.class);
verify(display).text(Component.text("Base: Alice"));
verify(display).setBillboard(Display.Billboard.CENTER);
verify(display).setViewRange(16);
verify(display).setLineWidth(512); // UUID fallback names must fit on one stacked label line.
verify(display).setPersistent(false);
verify(packets).nextId(world);
verify(packets).spawn(display, label, 42);
var order = inOrder(packets);
order.verify(packets).send(viewer, spawn);
order.verify(packets).send(viewer, metadata);
verify(world, atLeastOnce()).getUID();
verifyNoMoreInteractions(world); // No spawn, chunk lookup, chunk load or world entity registration.
verify(viewer, never()).getLocation(); // Anchor does not follow the visitor.
verify(display, never()).getEntityId(); // Unspawned displays have no assigned native ID.
}
@Test
void removesOnExitReplacesChangedLabelsAndClearsOnDisable() {
labels.render(viewer, Map.of(owner, label));
var changed = new BaseIdentification.Label(owner, "Base: Renamed", label.world(), 200.5, 66.25, -99.5, 16);
labels.render(viewer, Map.of(owner, changed));
verify(packets).remove(42);
verify(packets).spawn(display, changed, 43);
labels.render(viewer, Map.of());
verify(packets).remove(43);
labels.render(viewer, Map.of(owner, label));
labels.clear(viewer);
verify(packets).remove(44);
clearInvocations(packets);
labels.clear(viewer);
verifyNoInteractions(packets);
}
@Test
void worldTransitionAndDisconnectForgetWithoutSendingOldIdsIntoNewConnections() {
labels.render(viewer, Map.of(owner, label));
var other = mock(World.class);
when(other.getUID()).thenReturn(UUID.randomUUID());
when(viewer.getWorld()).thenReturn(other);
clearInvocations(packets);
labels.render(viewer, Map.of(owner, label));
verifyNoInteractions(packets);
when(viewer.getWorld()).thenReturn(world);
labels.render(viewer, Map.of(owner, label));
labels.forget(viewer.getUniqueId());
clearInvocations(packets);
labels.clear(viewer);
verifyNoInteractions(packets);
}
@Test
void failedPartialSpawnIsRemovedBeforeRetryRatherThanLeakingOrDuplicating() {
doThrow(new IllegalStateException("closed channel")).doNothing().when(packets).send(viewer, metadata);
assertThrows(IllegalStateException.class, () -> labels.render(viewer, Map.of(owner, label)));
labels.render(viewer, Map.of(owner, label));
var order = inOrder(packets);
order.verify(packets).send(viewer, spawn);
order.verify(packets).send(viewer, metadata);
order.verify(packets).remove(42);
order.verify(packets).send(viewer, remove);
order.verify(packets).spawn(display, label, 43);
order.verify(packets).send(viewer, spawn);
order.verify(packets).send(viewer, metadata);
}
}