From 51c7853b7b43f8fac21cced6df484809caaf3dfc Mon Sep 17 00:00:00 2001 From: William Date: Sun, 19 Feb 2023 23:08:17 +0000 Subject: [PATCH] Move to using Protocolize for scoreboard team handling using fake scoreboard teams --- README.md | 2 +- build.gradle | 5 + .../net/william278/velocitab/Velocitab.java | 18 +- .../velocitab/luckperms/LuckPermsHook.java | 9 +- .../velocitab/packet/ScoreboardManager.java | 62 +++++ .../velocitab/packet/UpdateTeamsPacket.java | 226 ++++++++++++++++++ .../velocitab/player/TabPlayer.java | 53 +--- .../velocitab/tab/PlayerTabList.java | 94 ++++---- 8 files changed, 373 insertions(+), 96 deletions(-) create mode 100644 src/main/java/net/william278/velocitab/packet/ScoreboardManager.java create mode 100644 src/main/java/net/william278/velocitab/packet/UpdateTeamsPacket.java diff --git a/README.md b/README.md index d226991..09b100a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ # Velocitab [![Discord](https://img.shields.io/discord/818135932103557162?color=7289da&logo=discord)](https://discord.gg/tVYhJfyDWG) -A very simple (sorted) Velocity TAB plugin \ No newline at end of file +A very simple (sorted) Velocity TAB plugin. Requires [Protocolize](https://github.com/Exceptionflug/protocolize) v2.2.5 to be installed on your proxy. \ No newline at end of file diff --git a/build.gradle b/build.gradle index 8cd6363..e6bd088 100644 --- a/build.gradle +++ b/build.gradle @@ -12,18 +12,23 @@ repositories { maven { url = 'https://repo.papermc.io/repository/maven-public/' } maven { url = 'https://jitpack.io' } maven { url = 'https://repo.minebench.de/' } + maven { url = 'https://mvn.exceptionflug.de/repository/exceptionflug-public/' } } dependencies { compileOnly 'com.velocitypowered:velocity-api:3.1.1' compileOnly 'net.luckperms:api:5.4' + compileOnly 'dev.simplix:protocolize-api:2.2.5' + compileOnly 'io.netty:netty-codec-http:4.1.86.Final' implementation 'org.apache.commons:commons-text:1.10.0' implementation 'net.william278:Annotaml:2.0.1' implementation 'dev.dejvokep:boosted-yaml:1.3.1' implementation 'de.themoep:minedown-adventure:1.7.1-SNAPSHOT' + implementation 'org.projectlombok:lombok:1.18.26' annotationProcessor 'com.velocitypowered:velocity-api:3.1.1' + annotationProcessor 'org.projectlombok:lombok:1.18.26' } shadowJar { diff --git a/src/main/java/net/william278/velocitab/Velocitab.java b/src/main/java/net/william278/velocitab/Velocitab.java index 4748090..b174b21 100644 --- a/src/main/java/net/william278/velocitab/Velocitab.java +++ b/src/main/java/net/william278/velocitab/Velocitab.java @@ -11,6 +11,7 @@ import com.velocitypowered.api.proxy.ProxyServer; import net.william278.annotaml.Annotaml; import net.william278.velocitab.config.Settings; import net.william278.velocitab.luckperms.LuckPermsHook; +import net.william278.velocitab.packet.ScoreboardManager; import net.william278.velocitab.player.Role; import net.william278.velocitab.player.TabPlayer; import net.william278.velocitab.tab.PlayerTabList; @@ -30,7 +31,10 @@ import java.util.Optional; description = "Simple velocity TAB menu plugin", url = "https://william278.net/", authors = {"William278"}, - dependencies = {@Dependency(id = "luckperms", optional = true)} + dependencies = { + @Dependency(id = "protocolize"), + @Dependency(id = "luckperms", optional = true) + } ) public class Velocitab { @@ -40,6 +44,7 @@ public class Velocitab { private final Path dataDirectory; private PlayerTabList tabList; private LuckPermsHook luckPerms; + private ScoreboardManager scoreboardManager; @Inject public Velocitab(ProxyServer server, Logger logger, @DataDirectory Path dataDirectory) { @@ -52,6 +57,7 @@ public class Velocitab { public void onProxyInitialization(ProxyInitializeEvent event) { loadSettings(); loadHooks(); + prepareScoreboardManager(); prepareTabList(); logger.info("Successfully enabled Velocitab v" + BuildConstants.VERSION); } @@ -92,6 +98,16 @@ public class Velocitab { } } + private void prepareScoreboardManager() { + this.scoreboardManager = new ScoreboardManager(this); + scoreboardManager.registerPacket(); + } + + @NotNull + public ScoreboardManager getScoreboardManager() { + return scoreboardManager; + } + @NotNull public PlayerTabList getTabList() { return tabList; diff --git a/src/main/java/net/william278/velocitab/luckperms/LuckPermsHook.java b/src/main/java/net/william278/velocitab/luckperms/LuckPermsHook.java index 6de99ff..5bd2ea5 100644 --- a/src/main/java/net/william278/velocitab/luckperms/LuckPermsHook.java +++ b/src/main/java/net/william278/velocitab/luckperms/LuckPermsHook.java @@ -10,7 +10,6 @@ import net.luckperms.api.model.group.Group; import net.luckperms.api.model.user.User; import net.william278.velocitab.Velocitab; import net.william278.velocitab.player.Role; -import net.william278.velocitab.tab.PlayerTabList; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -45,12 +44,8 @@ public class LuckPermsHook { @Subscribe public void onLuckPermsGroupUpdate(@NotNull UserDataRecalculateEvent event) { - plugin.getServer().getPlayer(event.getUser().getUniqueId()).ifPresent(player -> { - final PlayerTabList tabList = plugin.getTabList(); - tabList.removePlayer(player); - tabList.addPlayer(plugin.getTabPlayer(player)); - tabList.refreshHeaderAndFooter(); - }); + plugin.getServer().getPlayer(event.getUser().getUniqueId()) + .ifPresent(player -> plugin.getTabList().updatePlayer(plugin.getTabPlayer(player))); } private OptionalInt getWeight(@Nullable String groupName) { diff --git a/src/main/java/net/william278/velocitab/packet/ScoreboardManager.java b/src/main/java/net/william278/velocitab/packet/ScoreboardManager.java new file mode 100644 index 0000000..84617cf --- /dev/null +++ b/src/main/java/net/william278/velocitab/packet/ScoreboardManager.java @@ -0,0 +1,62 @@ +package net.william278.velocitab.packet; + +import com.velocitypowered.api.proxy.Player; +import dev.simplix.protocolize.api.PacketDirection; +import dev.simplix.protocolize.api.Protocol; +import dev.simplix.protocolize.api.Protocolize; +import net.william278.velocitab.Velocitab; +import net.william278.velocitab.player.TabPlayer; +import org.jetbrains.annotations.NotNull; + +import java.util.HashMap; +import java.util.UUID; + +public class ScoreboardManager { + + private final Velocitab plugin; + + private final HashMap fauxTeams; + + public ScoreboardManager(@NotNull Velocitab velocitab) { + this.plugin = velocitab; + this.fauxTeams = new HashMap<>(); + } + + public void registerPacket() { + Protocolize.protocolRegistration().registerPacket( + UpdateTeamsPacket.MAPPINGS, + Protocol.PLAY, + PacketDirection.CLIENTBOUND, + UpdateTeamsPacket.class + ); + } + + public void setPlayerTeam(@NotNull TabPlayer player) { + removeTeam(player.getPlayer()); + createTeam(player.getTeamName(), player.getPlayer()); + } + + private void createTeam(@NotNull String teamName, @NotNull Player member) { + final UUID uuid = member.getUniqueId(); + final UpdateTeamsPacket createTeamPacket = UpdateTeamsPacket.create(teamName, member.getUsername()); + plugin.getServer().getAllPlayers().stream() + .map(Player::getUniqueId) + .map(Protocolize.playerProvider()::player) + .forEach(protocolPlayer -> protocolPlayer.sendPacket(createTeamPacket)); + fauxTeams.put(uuid, teamName); + } + + public void removeTeam(@NotNull Player member) { + final UUID uuid = member.getUniqueId(); + if (!fauxTeams.containsKey(uuid)) { + return; + } + final UpdateTeamsPacket removeTeamPacket = UpdateTeamsPacket.remove(fauxTeams.get(uuid)); + plugin.getServer().getAllPlayers().stream() + .map(Player::getUniqueId) + .map(Protocolize.playerProvider()::player) + .forEach(protocolPlayer -> protocolPlayer.sendPacket(removeTeamPacket)); + fauxTeams.remove(uuid); + } + +} diff --git a/src/main/java/net/william278/velocitab/packet/UpdateTeamsPacket.java b/src/main/java/net/william278/velocitab/packet/UpdateTeamsPacket.java new file mode 100644 index 0000000..a5dbbc4 --- /dev/null +++ b/src/main/java/net/william278/velocitab/packet/UpdateTeamsPacket.java @@ -0,0 +1,226 @@ +package net.william278.velocitab.packet; + +import dev.simplix.protocolize.api.PacketDirection; +import dev.simplix.protocolize.api.mapping.AbstractProtocolMapping; +import dev.simplix.protocolize.api.mapping.ProtocolIdMapping; +import dev.simplix.protocolize.api.packet.AbstractPacket; +import dev.simplix.protocolize.api.util.ProtocolUtil; +import io.netty.buffer.ByteBuf; +import lombok.*; +import lombok.experimental.Accessors; +import org.apache.commons.text.StringEscapeUtils; +import org.jetbrains.annotations.NotNull; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import static dev.simplix.protocolize.api.util.ProtocolVersions.MINECRAFT_1_19; +import static dev.simplix.protocolize.api.util.ProtocolVersions.MINECRAFT_1_19_3; + +@Getter +@Setter +@ToString +@AllArgsConstructor +@NoArgsConstructor +@EqualsAndHashCode(callSuper = false) +@Accessors(fluent = true) +public class UpdateTeamsPacket extends AbstractPacket { + + protected static final List MAPPINGS = List.of( + AbstractProtocolMapping.rangedIdMapping(MINECRAFT_1_19, MINECRAFT_1_19_3, 0x56) + ); + + private String teamName; + private UpdateMode mode; + private String displayName; + private List friendlyFlags; + private NameTagVisibility nameTagVisibility; + private CollisionRule collisionRule; + private int color; + private String prefix; + private String suffix; + private List entities; + + @NotNull + public static UpdateTeamsPacket create(@NotNull String teamName, @NotNull String member) { + if (teamName.length() > 16) { + teamName = teamName.substring(0, 16); + } + final UpdateTeamsPacket updateTeamsPacket = new UpdateTeamsPacket(); + updateTeamsPacket.teamName(teamName); + updateTeamsPacket.mode(UpdateMode.CREATE); + updateTeamsPacket.displayName(getChatString(teamName)); + updateTeamsPacket.friendlyFlags(List.of(FriendlyFlag.CAN_HURT_FRIENDLY)); + updateTeamsPacket.nameTagVisibility(NameTagVisibility.ALWAYS); + updateTeamsPacket.collisionRule(CollisionRule.ALWAYS); + updateTeamsPacket.color(0); + updateTeamsPacket.prefix(getChatString("")); + updateTeamsPacket.suffix(getChatString("")); + updateTeamsPacket.entities(List.of(member)); + return updateTeamsPacket; + } + + @NotNull + public static UpdateTeamsPacket remove(@NotNull String teamName) { + if (teamName.length() > 16) { + teamName = teamName.substring(0, 16); + } + final UpdateTeamsPacket updateTeamsPacket = new UpdateTeamsPacket(); + updateTeamsPacket.teamName(teamName); + updateTeamsPacket.mode(UpdateMode.REMOVE); + return updateTeamsPacket; + } + + @Override + public void read(ByteBuf byteBuf, PacketDirection packetDirection, int i) { + teamName = ProtocolUtil.readString(byteBuf); + mode = UpdateMode.byId(byteBuf.readByte()); + if (mode == UpdateMode.REMOVE) { + return; + } + if (mode == UpdateMode.CREATE || mode == UpdateMode.UPDATE_INFO) { + displayName = ProtocolUtil.readString(byteBuf); + friendlyFlags = FriendlyFlag.fromBitMask(byteBuf.readByte()); + nameTagVisibility = NameTagVisibility.byId(ProtocolUtil.readString(byteBuf)); + collisionRule = CollisionRule.byId(ProtocolUtil.readString(byteBuf)); + color = byteBuf.readByte(); + prefix = ProtocolUtil.readString(byteBuf); + suffix = ProtocolUtil.readString(byteBuf); + } + if (mode == UpdateMode.CREATE || mode == UpdateMode.ADD_PLAYERS || mode == UpdateMode.REMOVE_PLAYERS) { + int entityCount = ProtocolUtil.readVarInt(byteBuf); + for (int j = 0; j < entityCount; j++) { + entities.add(ProtocolUtil.readString(byteBuf)); + } + } + } + + @Override + public void write(ByteBuf byteBuf, PacketDirection packetDirection, int i) { + ProtocolUtil.writeString(byteBuf, teamName); + byteBuf.writeByte(mode.id()); + if (mode == UpdateMode.REMOVE) { + return; + } + if (mode == UpdateMode.CREATE || mode == UpdateMode.UPDATE_INFO) { + ProtocolUtil.writeString(byteBuf, displayName); + byteBuf.writeByte(FriendlyFlag.toBitMask(friendlyFlags)); + ProtocolUtil.writeString(byteBuf, nameTagVisibility.id()); + ProtocolUtil.writeString(byteBuf, collisionRule.id()); + byteBuf.writeByte(color); + ProtocolUtil.writeString(byteBuf, prefix); + ProtocolUtil.writeString(byteBuf, suffix); + } + if (mode == UpdateMode.CREATE || mode == UpdateMode.ADD_PLAYERS || mode == UpdateMode.REMOVE_PLAYERS) { + ProtocolUtil.writeVarInt(byteBuf, entities.size()); + for (String entity : entities) { + ProtocolUtil.writeString(byteBuf, entity); + } + } + } + + @NotNull + private static String getChatString(@NotNull String string) { + return "{\"text\":\"" + StringEscapeUtils.escapeJson(string) + "\"}"; + } + + public enum UpdateMode { + CREATE(0), + REMOVE(1), + UPDATE_INFO(2), + ADD_PLAYERS(3), + REMOVE_PLAYERS(4); + + private final int id; + + UpdateMode(int id) { + this.id = id; + } + + public int id() { + return id; + } + + public static UpdateMode byId(int id) { + return Arrays.stream(values()) + .filter(mode -> mode.id == id) + .findFirst() + .orElse(null); + } + } + + public enum FriendlyFlag { + CAN_HURT_FRIENDLY(0x01), + CAN_HURT_FRIENDLY_FIRE(0x02); + + private final int id; + + FriendlyFlag(int id) { + this.id = id; + } + + @NotNull + public static List fromBitMask(int bitMask) { + return Arrays.stream(values()) + .filter(flag -> (bitMask & flag.id) != 0) + .collect(Collectors.toList()); + } + + public static int toBitMask(@NotNull List friendlyFlags) { + int bitMask = 0; + for (FriendlyFlag friendlyFlag : friendlyFlags) { + bitMask |= friendlyFlag.id; + } + return bitMask; + } + } + + public enum NameTagVisibility { + ALWAYS("always"), + NEVER("never"), + HIDE_FOR_OTHER_TEAMS("hideForOtherTeams"), + HIDE_FOR_OWN_TEAM("hideForOwnTeam"); + + private final String id; + + NameTagVisibility(String id) { + this.id = id; + } + + public String id() { + return id; + } + + public static NameTagVisibility byId(String id) { + return Arrays.stream(values()) + .filter(visibility -> visibility.id.equals(id)) + .findFirst() + .orElse(null); + } + } + + public enum CollisionRule { + ALWAYS("always"), + NEVER("never"), + PUSH_OTHER_TEAMS("pushOtherTeams"), + PUSH_OWN_TEAM("pushOwnTeam"); + + private final String id; + + CollisionRule(String id) { + this.id = id; + } + + public String id() { + return id; + } + + public static CollisionRule byId(String id) { + return Arrays.stream(values()) + .filter(rule -> rule.id.equals(id)) + .findFirst() + .orElse(null); + } + } +} diff --git a/src/main/java/net/william278/velocitab/player/TabPlayer.java b/src/main/java/net/william278/velocitab/player/TabPlayer.java index f1f3d74..844831f 100644 --- a/src/main/java/net/william278/velocitab/player/TabPlayer.java +++ b/src/main/java/net/william278/velocitab/player/TabPlayer.java @@ -1,9 +1,6 @@ package net.william278.velocitab.player; import com.velocitypowered.api.proxy.Player; -import com.velocitypowered.api.proxy.player.TabList; -import com.velocitypowered.api.proxy.player.TabListEntry; -import com.velocitypowered.api.util.GameProfile; import de.themoep.minedown.adventure.MineDown; import net.kyori.adventure.text.Component; import net.william278.velocitab.Velocitab; @@ -11,26 +8,15 @@ import net.william278.velocitab.config.Placeholder; import net.william278.velocitab.tab.PlayerTabList; import org.jetbrains.annotations.NotNull; -import java.util.Arrays; -import java.util.Random; -import java.util.UUID; -import java.util.concurrent.TimeUnit; - public final class TabPlayer implements Comparable { - private static final int DEFAULT_LATENCY = 3; private final Player player; private final Role role; - private final GameProfile profile; + private final int highestWeight; public TabPlayer(@NotNull Player player, @NotNull Role role, int highestWeight) { this.player = player; this.role = role; - final String profileName = role.getStringComparableWeight(highestWeight) + getServerName() + player.getUsername(); - this.profile = new GameProfile( - new UUID(0, new Random().nextLong()), - profileName.length() > 16 ? profileName.substring(0, 16) : profileName, - player.getGameProfileProperties() - ); + this.highestWeight = highestWeight; } @NotNull @@ -43,11 +29,6 @@ public final class TabPlayer implements Comparable { return role; } - @NotNull - public GameProfile getProfile() { - return profile; - } - @NotNull public String getServerName() { return player.getCurrentServer() @@ -56,41 +37,19 @@ public final class TabPlayer implements Comparable { } @NotNull - private Component getDisplayName(@NotNull Velocitab plugin) { + public Component getDisplayName(@NotNull Velocitab plugin) { return new MineDown(Placeholder.format(plugin.getSettings().getFormat(), plugin, this)).toComponent(); } - private TabListEntry getEntry(@NotNull Velocitab plugin, @NotNull TabList tabList) { - return TabListEntry.builder() - .displayName(getDisplayName(plugin)) - .latency(DEFAULT_LATENCY) - .profile(profile) - .tabList(tabList) - .build(); + @NotNull + public String getTeamName() { + return role.getStringComparableWeight(highestWeight) + "-" + getServerName() + "-" + player.getUsername(); } public void sendHeaderAndFooter(@NotNull PlayerTabList tabList) { this.player.sendPlayerListHeaderAndFooter(tabList.getHeader(this), tabList.getFooter(this)); } - public void addPlayer(@NotNull TabPlayer player, @NotNull Velocitab plugin) { - this.player.getTabList().addEntry(player.getEntry(plugin, this.player.getTabList())); - removeUuidPlayer(plugin, player.getPlayer().getUniqueId()); - } - - public void removePlayer(@NotNull TabPlayer player, @NotNull Velocitab plugin) { - this.player.getTabList().removeEntry(player.getProfile().getId()); - removeUuidPlayer(plugin, player.getPlayer().getUniqueId()); - } - - public void removeUuidPlayer(@NotNull Velocitab plugin, @NotNull UUID... uuid) { - plugin.getServer().getScheduler() - .buildTask(plugin, () -> Arrays.stream(uuid).forEach(this.player.getTabList()::removeEntry)) - .delay(500, TimeUnit.MILLISECONDS) - .schedule(); - } - - @Override public int compareTo(@NotNull TabPlayer o) { final int roleDifference = role.compareTo(o.role); diff --git a/src/main/java/net/william278/velocitab/tab/PlayerTabList.java b/src/main/java/net/william278/velocitab/tab/PlayerTabList.java index 357d0ac..ee53e03 100644 --- a/src/main/java/net/william278/velocitab/tab/PlayerTabList.java +++ b/src/main/java/net/william278/velocitab/tab/PlayerTabList.java @@ -3,7 +3,6 @@ package net.william278.velocitab.tab; import com.velocitypowered.api.event.Subscribe; import com.velocitypowered.api.event.connection.DisconnectEvent; import com.velocitypowered.api.event.player.ServerPostConnectEvent; -import com.velocitypowered.api.proxy.Player; import de.themoep.minedown.adventure.MineDown; import net.kyori.adventure.text.Component; import net.william278.velocitab.Velocitab; @@ -13,7 +12,7 @@ import org.jetbrains.annotations.NotNull; import java.util.ArrayList; import java.util.List; -import java.util.Optional; +import java.util.concurrent.TimeUnit; public class PlayerTabList { private final Velocitab plugin; @@ -27,33 +26,66 @@ public class PlayerTabList { @SuppressWarnings("UnstableApiUsage") @Subscribe public void onPlayerJoin(@NotNull ServerPostConnectEvent event) { - // Remove previous Tab entries for players when they move servers - if (event.getPreviousServer() != null) { - removePlayer(event.getPlayer()); + // Remove the player from the tracking list if they are switching servers + if (event.getPreviousServer() == null) { + players.removeIf(player -> player.getPlayer().getUniqueId().equals(event.getPlayer().getUniqueId())); } - final TabPlayer player = plugin.getTabPlayer(event.getPlayer()); + // Add the player to the tracking list + players.add(plugin.getTabPlayer(event.getPlayer())); - // Reset existing tab list - player.getPlayer().getTabList().clearHeaderAndFooter(); - if (!player.getPlayer().getTabList().getEntries().isEmpty()) { - player.getPlayer().getTabList().getEntries().clear(); - } - - // Show existing list to new player - players.forEach(listPlayer -> player.addPlayer(listPlayer, plugin)); - addPlayer(player); - refreshHeaderAndFooter(); + // Update the tab list of all players + plugin.getServer().getScheduler().buildTask(plugin, this::updateList) + .delay(500, TimeUnit.MILLISECONDS) + .schedule(); } @Subscribe public void onPlayerQuit(@NotNull DisconnectEvent event) { - try { - removePlayer(event.getPlayer()); - refreshHeaderAndFooter(); - } catch (Exception ignored) { - // Ignore when server shutting down - } + // Remove the player from the tracking list + players.removeIf(player -> player.getPlayer().getUniqueId().equals(event.getPlayer().getUniqueId())); + + // Remove the player from the tab list of all other players + plugin.getScoreboardManager().removeTeam(event.getPlayer()); + plugin.getServer().getAllPlayers().forEach(player -> { + if (player.getTabList().containsEntry(event.getPlayer().getUniqueId())) { + player.getTabList().removeEntry(event.getPlayer().getUniqueId()); + } + }); + + // Update the tab list of all players + plugin.getServer().getScheduler().buildTask(plugin, this::updateList) + .delay(500, TimeUnit.MILLISECONDS) + .schedule(); + } + + public void updatePlayer(@NotNull TabPlayer tabPlayer) { + // Remove the existing player from the tracking list + players.removeIf(player -> player.getPlayer().getUniqueId().equals(tabPlayer.getPlayer().getUniqueId())); + + // Add the player to the tracking list + players.add(tabPlayer); + + // Update the player's team sorting + plugin.getScoreboardManager().removeTeam(tabPlayer.getPlayer()); + + // Update the tab list of all players + plugin.getServer().getScheduler().buildTask(plugin, this::updateList) + .delay(500, TimeUnit.MILLISECONDS) + .schedule(); + } + + private void updateList() { + players.forEach(player -> { + player.sendHeaderAndFooter(this); + player.getPlayer().getTabList().getEntries() + .forEach(entry -> players.stream() + .filter(p -> p.getPlayer().getGameProfile().getId().equals(entry.getProfile().getId())) + .findFirst().ifPresent(tabPlayer -> { + entry.setDisplayName(tabPlayer.getDisplayName(plugin)); + plugin.getScoreboardManager().setPlayerTeam(tabPlayer); + })); + }); } @NotNull @@ -66,22 +98,4 @@ public class PlayerTabList { return new MineDown(Placeholder.format(plugin.getSettings().getFooter(), plugin, player)).toComponent(); } - // Add a new tab player to the list and update for online players - public void addPlayer(@NotNull TabPlayer player) { - players.add(player); - players.forEach(tabPlayer -> tabPlayer.addPlayer(player, plugin)); - } - - public void removePlayer(@NotNull Player playerToRemove) { - final Optional quitTabPlayer = players.stream() - .filter(player -> player.getPlayer().equals(playerToRemove)).findFirst(); - if (quitTabPlayer.isPresent()) { - players.remove(quitTabPlayer.get()); - players.forEach(tabPlayer -> tabPlayer.removePlayer(quitTabPlayer.get(), plugin)); - } - } - - public void refreshHeaderAndFooter() { - players.forEach(tabPlayer -> tabPlayer.sendHeaderAndFooter(this)); - } }