feat(base): identify nearby bases with private owner labels
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user