diff --git a/api/src/main/java/com/velocitypowered/api/network/ProtocolVersion.java b/api/src/main/java/com/velocitypowered/api/network/ProtocolVersion.java index 2677f4a5ac..db3ef325d0 100644 --- a/api/src/main/java/com/velocitypowered/api/network/ProtocolVersion.java +++ b/api/src/main/java/com/velocitypowered/api/network/ProtocolVersion.java @@ -11,7 +11,6 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.Sets; import com.velocitypowered.api.util.Ordered; -import java.util.Arrays; import java.util.EnumSet; import java.util.HashMap; import java.util.List; @@ -309,6 +308,25 @@ public boolean isSupported() { */ private static final int SNAPSHOT_BIT = 30; + /** + * A map linking each user-facing version name (e.g. {@code "1.20.4"}) to its + * {@link ProtocolVersion}. + */ + private static final ImmutableMap<@NotNull String, @NotNull ProtocolVersion> NAME_TO_PROTOCOL_CONSTANT; + + static { + Map byName = new HashMap<>(); + for (ProtocolVersion version : values()) { + for (String name : version.names) { + if (byName.put(name, version) != null) { + throw new IllegalStateException("Multiple versions mapped to '" + name + "' found!"); + } + } + } + + NAME_TO_PROTOCOL_CONSTANT = ImmutableMap.copyOf(byName); + } + /** * The protocol version number used by the Minecraft network protocol. */ @@ -488,10 +506,7 @@ public static ProtocolVersion getProtocolVersion(int protocol) { * @return the protocol version */ public static ProtocolVersion getVersionByName(String version) { - return Arrays.stream(ProtocolVersion.values()) - .filter(protocolVersion -> Arrays.asList(protocolVersion.names).contains(version)) - .findFirst() - .orElse(ProtocolVersion.MINECRAFT_1_7_2); + return NAME_TO_PROTOCOL_CONSTANT.getOrDefault(version, ProtocolVersion.MINECRAFT_1_7_2); } /** diff --git a/api/src/main/java/com/velocitypowered/api/proxy/ProxyServer.java b/api/src/main/java/com/velocitypowered/api/proxy/ProxyServer.java index 090cad79b3..3a271050d8 100644 --- a/api/src/main/java/com/velocitypowered/api/proxy/ProxyServer.java +++ b/api/src/main/java/com/velocitypowered/api/proxy/ProxyServer.java @@ -27,6 +27,7 @@ import java.util.UUID; import net.kyori.adventure.audience.Audience; import net.kyori.adventure.text.Component; +import org.jetbrains.annotations.UnmodifiableView; /** * Provides an interface to a Minecraft server proxy. @@ -80,9 +81,20 @@ public interface ProxyServer extends Audience { * of all players online. * * @return the players online on this proxy + * @deprecated use {@link #getOnlinePlayers()} instead (faster, doesn't make copy) */ + @Deprecated Collection getAllPlayers(); + /** + * Retrieves all players currently connected to this proxy. This is an unmodifiable view of the live + * collection of online players. + * + * @return the players online on this proxy + */ + @UnmodifiableView + Collection getOnlinePlayers(); + /** * Returns the number of players currently connected to this proxy. * diff --git a/proxy/src/main/java/com/velocityctd/proxy/cluster/local/LocalClusterPlayerService.java b/proxy/src/main/java/com/velocityctd/proxy/cluster/local/LocalClusterPlayerService.java index 898d8c58a5..1a4922f30a 100644 --- a/proxy/src/main/java/com/velocityctd/proxy/cluster/local/LocalClusterPlayerService.java +++ b/proxy/src/main/java/com/velocityctd/proxy/cluster/local/LocalClusterPlayerService.java @@ -17,6 +17,7 @@ package com.velocityctd.proxy.cluster.local; +import com.google.common.collect.Collections2; import com.velocityctd.proxy.cluster.VelocityClusterPlayer; import com.velocityctd.proxy.cluster.VelocityClusterPlayerService; import com.velocitypowered.api.proxy.player.PlayerSettings; @@ -53,17 +54,14 @@ public int getPlayersOnServerCount(String serverName) { @Override public Collection getAllPlayers() { - return this.server.getAllPlayers().stream() - .map(this::toLocalPlayer) - .toList(); + return Collections2.transform(this.server.getOnlinePlayers(), this::toLocalPlayer); } @Override public Collection getPlayersOnServer(String serverName) { return this.server.getServer(serverName) - .map(rs -> rs.getPlayersConnected().stream() - .map(this::toLocalPlayer) - .toList()) + .>map( + rs -> Collections2.transform(rs.getPlayersConnected(), this::toLocalPlayer)) .orElse(List.of()); } @@ -72,7 +70,7 @@ public Collection getPlayersOnProxy(String proxyId) { if (!this.server.getProxyId().equalsIgnoreCase(proxyId)) { return List.of(); } - return getAllPlayers(); + return this.getAllPlayers(); } @Override @@ -109,7 +107,7 @@ public void onPlayerSettingsChange(ConnectedPlayer player, PlayerSettings settin @Override public Collection getPlayerNames() { - return this.server.getAllPlayers().stream() + return this.server.getOnlinePlayers().stream() .map(ConnectedPlayer::getUsername) .toList(); } diff --git a/proxy/src/main/java/com/velocityctd/proxy/command/PlayerIdentifier.java b/proxy/src/main/java/com/velocityctd/proxy/command/PlayerIdentifier.java index f397409a1f..2e56f35d47 100644 --- a/proxy/src/main/java/com/velocityctd/proxy/command/PlayerIdentifier.java +++ b/proxy/src/main/java/com/velocityctd/proxy/command/PlayerIdentifier.java @@ -26,6 +26,7 @@ import com.velocitypowered.proxy.server.VelocityRegisteredServer; import java.util.Collection; import java.util.Collections; +import java.util.List; import org.checkerframework.checker.nullness.qual.Nullable; /** @@ -86,7 +87,10 @@ public String name() { } private static Result success(Type type, Collection players, @Nullable String name) { - return new Result(type, true, players, name); + // Snapshot to insulate consumers from lazy/live views (e.g. Collections2.transform on the + // online-players map): guarantees stable size and iteration regardless of joins/leaves + // happening between calls to players(). + return new Result(type, true, List.copyOf(players), name); } private static Result failure(Type type, @Nullable String name) { diff --git a/proxy/src/main/java/com/velocityctd/proxy/command/builtin/QueueAdminCommand.java b/proxy/src/main/java/com/velocityctd/proxy/command/builtin/QueueAdminCommand.java index 42aa75cddb..2367e38581 100644 --- a/proxy/src/main/java/com/velocityctd/proxy/command/builtin/QueueAdminCommand.java +++ b/proxy/src/main/java/com/velocityctd/proxy/command/builtin/QueueAdminCommand.java @@ -80,6 +80,7 @@ public List aliases() { @Override public BrigadierCommand build() { + String label = label(); LiteralCommandNode listQueues = BrigadierCommand.literalArgumentBuilder("listqueues") .requires(source -> source.getPermissionValue("velocity.queue.admin.listqueues") == Tristate.TRUE) .executes(this::listQueues) @@ -173,14 +174,14 @@ public BrigadierCommand build() { return new BrigadierCommand( commands.stream() .reduce( - BrigadierCommand.literalArgumentBuilder("queueadmin") + BrigadierCommand.literalArgumentBuilder(label) .executes(ctx -> { CommandSource source = ctx.getSource(); String availableCommands = commands.stream() .filter(e -> e.getRequirement().test(source)) .map(LiteralCommandNode::getName) .collect(Collectors.joining("|")); - String commandText = "/queueadmin <%s>".formatted(availableCommands); + String commandText = "/%s <%s>".formatted(label, availableCommands); source.sendMessage(Component.text(commandText, NamedTextColor.RED)); return 0; }) diff --git a/proxy/src/main/java/com/velocityctd/proxy/config/migration/CtdConfigMigrations.java b/proxy/src/main/java/com/velocityctd/proxy/config/migration/CtdConfigMigrations.java index 8003a7c521..6cb1e28a53 100644 --- a/proxy/src/main/java/com/velocityctd/proxy/config/migration/CtdConfigMigrations.java +++ b/proxy/src/main/java/com/velocityctd/proxy/config/migration/CtdConfigMigrations.java @@ -125,23 +125,6 @@ public static List createCtdMigrations() { List.of("joinqueue", "queue") ), - // [command-aliases] - migration( - "What commands should have aliases for simpler execution that\n" - + " do not already have a more advanced function or implementation.", - "command-aliases.hub", - List.of("lobby", "return") - ), - - // [proxy-command-aliases] - migration( - "Proxy command aliases create new commands that execute other commands when invoked.\n" - + " This is similar to Bukkit's commands.yml functionality.\n" - + " Adding multiple aliases executes multiple commands.", - "proxy-command-aliases.examplealias", - List.of("velocity help") - ), - // [advanced] migration( "Enables the execution of illegal characters in chat and only allows\n" diff --git a/proxy/src/main/java/com/velocityctd/proxy/redis/depot/player/PlayerDepotService.java b/proxy/src/main/java/com/velocityctd/proxy/redis/depot/player/PlayerDepotService.java index 0dc6cfeea4..1551c7cd09 100644 --- a/proxy/src/main/java/com/velocityctd/proxy/redis/depot/player/PlayerDepotService.java +++ b/proxy/src/main/java/com/velocityctd/proxy/redis/depot/player/PlayerDepotService.java @@ -90,7 +90,7 @@ public PlayerDepotService(@NotNull VelocityRedis redis) { @Override public void teardown() { - for (ConnectedPlayer player : this.server.getAllPlayers()) { + for (ConnectedPlayer player : this.server.getOnlinePlayers()) { this.depot.remove(player.getUniqueId()); } @@ -312,7 +312,7 @@ private void syncPlayerEntries() { return; } - for (ConnectedPlayer player : this.server.getAllPlayers()) { + for (ConnectedPlayer player : this.server.getOnlinePlayers()) { if (this.depot.contains(player.getUniqueId())) { continue; } diff --git a/proxy/src/main/java/com/velocityctd/proxy/util/ParsingUtils.java b/proxy/src/main/java/com/velocityctd/proxy/util/ParsingUtils.java new file mode 100644 index 0000000000..20855ec1aa --- /dev/null +++ b/proxy/src/main/java/com/velocityctd/proxy/util/ParsingUtils.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2026 Velocity Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.velocityctd.proxy.util; + +import java.util.function.Function; + +public class ParsingUtils { + + /** + * Replaces variables of the form {name} in the input string with values produced + * by the given mapper. + * + *

Each variable is delimited by a literal { and }. The mapper is + * called with the inner name only (no braces). If it returns a non-null value, that value is + * substituted in place; if it returns {@code null}, the original {name} is + * written back unchanged so unknown placeholders pass through intact. Text outside variables + * is passed through unchanged. Nesting is not supported: a { inside a variable is + * treated as part of the name. If the input ends while a variable is still open (no matching + * }), the opening { and any partial content are written back + * unchanged. + * + * @param input the string to process + * @param variableMapper function mapping a variable name (without braces) to its replacement, + * or {@code null} to leave the {name} literal in the output + * @return the input with all known variables substituted + */ + public static String parseVariables(String input, Function variableMapper) { + StringBuilder out = new StringBuilder(input.length()); + StringBuilder variable = new StringBuilder(); + boolean inVariable = false; + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + if (!inVariable) { + if (c == '{') { + // start reading variable + inVariable = true; + } else { + // pass-through output string + out.append(c); + } + } else { + if (c == '}') { + // write variable value + String value = variableMapper.apply(variable.toString()); + if (value == null) { + // pass-through unknown variables as-is + out.append('{'); + out.append(variable); + out.append('}'); + } else { + // write variable value + out.append(value); + } + + variable.setLength(0); + inVariable = false; + } else { + // pass-through variable name + variable.append(c); + } + } + } + + if (inVariable) { + // unclosed '{', pass through as-is + out.append('{'); + out.append(variable); + } + + return out.toString(); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/Metrics.java b/proxy/src/main/java/com/velocitypowered/proxy/Metrics.java index b35f7ac113..c683745030 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/Metrics.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/Metrics.java @@ -106,6 +106,8 @@ static class VelocityMetrics { private static final Logger LOGGER = LogManager.getLogger(Metrics.class); + private static final Pattern MAJOR_VERSION_DIGITS = Pattern.compile("\\d+"); + static void startMetrics(VelocityServer server, VelocityConfiguration.Metrics metricsConfig) { Metrics metrics = new Metrics(LOGGER, 30992, metricsConfig.isEnabled()); @@ -144,7 +146,7 @@ static void startMetrics(VelocityServer server, VelocityConfiguration.Metrics me // of course, it really wouldn't be all that simple if they didn't add a quirk, now // would it valid strings for the major may potentially include values such as -ea to // denote a pre-release - Matcher versionMatcher = Pattern.compile("\\d+").matcher(majorVersion); + Matcher versionMatcher = MAJOR_VERSION_DIGITS.matcher(majorVersion); if (versionMatcher.find()) { majorVersion = versionMatcher.group(0); } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java index 6edab03269..a56c84fa27 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java @@ -138,6 +138,7 @@ import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.checker.nullness.qual.Nullable; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.UnmodifiableView; /** * Implementation of {@link ProxyServer}. @@ -666,7 +667,7 @@ public boolean reloadConfiguration() throws IOException { } if (!this.getConfiguration().getServerLinks().isEmpty()) { - for (ConnectedPlayer player : this.getAllPlayers()) { + for (ConnectedPlayer player : this.getOnlinePlayers()) { if (player.getProtocolVersion().noLessThan(ProtocolVersion.MINECRAFT_1_21)) { try { if (player.getProtocolState() == ProtocolState.CONFIGURATION || player.getProtocolState() == ProtocolState.PLAY) { @@ -1170,7 +1171,7 @@ public Optional getPlayer(UUID uuid) { public Collection matchPlayer(String partialName) { Objects.requireNonNull(partialName); - return getAllPlayers().stream().filter(p -> p.getUsername() + return getOnlinePlayers().stream().filter(p -> p.getUsername() .regionMatches(true, 0, partialName, 0, partialName.length())) .collect(Collectors.toList()); } @@ -1189,6 +1190,22 @@ public Collection getAllPlayers() { return ImmutableList.copyOf(connectionsByUuid.values()); } + @Override + public @UnmodifiableView Collection getOnlinePlayers() { + return Collections.unmodifiableCollection(connectionsByUuid.values()); + } + + /** + * Returns whether the given player is currently registered as online on this proxy. Uses an + * O(1) UUID lookup against the underlying connection map. + * + * @param player the player to check + * @return {@code true} if the same player instance is registered under its UUID + */ + public boolean isPlayerOnline(ConnectedPlayer player) { + return connectionsByUuid.get(player.getUniqueId()) == player; + } + @Override public int getPlayerCount() { return clusterPlayerService.getTotalPlayerCount(); @@ -1272,7 +1289,7 @@ public InetSocketAddress getBoundAddress() { @Override public @NonNull Iterable audiences() { - Collection connectedPlayers = getAllPlayers(); + Collection connectedPlayers = getOnlinePlayers(); Collection audiences = new ArrayList<>(connectedPlayers.size() + 1); audiences.add(console); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BungeeCordMessageResponder.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BungeeCordMessageResponder.java index 86d0b687ad..f8ef553681 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BungeeCordMessageResponder.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BungeeCordMessageResponder.java @@ -319,7 +319,7 @@ private void processPlayerList(ByteBufDataInput in) { out.writeUTF("ALL"); StringJoiner joiner = new StringJoiner(", "); - for (ConnectedPlayer online : proxy.getAllPlayers()) { + for (ConnectedPlayer online : proxy.getOnlinePlayers()) { joiner.add(online.getUsername()); } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientSettingsWrapper.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientSettingsWrapper.java index b618fe8556..ddd3448efe 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientSettingsWrapper.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientSettingsWrapper.java @@ -46,7 +46,7 @@ public class ClientSettingsWrapper implements PlayerSettings { @Override public Locale getLocale() { if (locale == null) { - locale = Locale.forLanguageTag(settings.getLocale().replaceAll("_", "-")); + locale = Locale.forLanguageTag(settings.getLocale().replace('_', '-')); } return locale; diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java index 679ab74252..a2d37271a0 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java @@ -1210,7 +1210,7 @@ private void tryAutoQueue(@NonNull VelocityServerConnection joinedServer) { tryAutoQueueTask = server.getScheduler().buildTask(VelocityVirtualPlugin.INSTANCE, () -> { // Safeguard if this player is offline. Should never be reached because the task // should be cancelled by ConnectedPlayer#disconnected. - if (!server.getAllPlayers().contains(this)) { + if (!server.isPlayerOnline(this)) { LOGGER.debug("Aborting auto-queueing player {} (now offline).", getUsername()); return; } @@ -1252,7 +1252,7 @@ private void tryQueueOnJoin(@NonNull VelocityServerConnection firstServer) { tryQueueOnJoinTask = server.getScheduler().buildTask(VelocityVirtualPlugin.INSTANCE, () -> { // Safeguard if this player is offline. Should never be reached because the task // should be cancelled by ConnectedPlayer#disconnected. - if (!server.getAllPlayers().contains(this)) { + if (!server.isPlayerOnline(this)) { LOGGER.debug("Aborting queue-on-join for player {} (now offline).", getUsername()); return; } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialLoginSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialLoginSessionHandler.java index 64b20fe5fe..87d45c6de9 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialLoginSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialLoginSessionHandler.java @@ -66,8 +66,6 @@ public class InitialLoginSessionHandler implements MinecraftSessionHandler { private static final Logger LOGGER = LogManager.getLogger(InitialLoginSessionHandler.class); - private static final ThreadLocalRandom RANDOM = ThreadLocalRandom.current(); - private static final String MOJANG_HASJOINED_URL = System.getProperty("mojang.sessionserver", "https://sessionserver.mojang.com/session/minecraft/hasJoined") @@ -278,7 +276,7 @@ public boolean handle(EncryptionResponsePacket packet) { private EncryptionRequestPacket generateEncryptionRequest() { byte[] verify = new byte[4]; - RANDOM.nextBytes(verify); + ThreadLocalRandom.current().nextBytes(verify); EncryptionRequestPacket request = new EncryptionRequestPacket(); request.setPublicKey(server.getServerKeyPair().getPublic().getEncoded()); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/util/FallbackServers.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/util/FallbackServers.java index 626b16fed4..2dd65f813e 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/util/FallbackServers.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/util/FallbackServers.java @@ -136,14 +136,10 @@ private static FallbackServers fromMapEntry(VelocityConfiguration config, String */ private static Optional getForcedHostFallbacks(VelocityConfiguration config, String virtualHost) { Map forcedHosts = config.getForcedHostEntries(); - Map.Entry exactMatch = forcedHosts.entrySet() - .stream() - .filter(e -> e.getKey().equalsIgnoreCase(virtualHost)) - .findAny() - .orElse(null); - + ForcedHostEntry exactMatch = forcedHosts.get(virtualHost); if (exactMatch != null) { - return Optional.of(fromMapEntry(config, virtualHost, exactMatch)); + return Optional.of(fromMapEntry(config, virtualHost, + Map.entry(virtualHost, exactMatch))); } // Check for wildcard ("*.example.com" matches "anything.example.com") @@ -161,7 +157,7 @@ private static Optional getForcedHostFallbacks(VelocityConfigur * Resolves the fallback server configuration for an inbound connection. * *

If the connection supplies a virtual host that matches a forced-host rule (exact or - * wildcard), the servers and filter from that rule are used. Otherwise the global + * wildcard), the servers and filter from that rule are used. Otherwise, the global * {@code attempt-connection-order} and dynamic fallback filter from the proxy configuration * are used, with {@link #matchedVirtualHostPattern()} left as {@code null}. * diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ServerListPingHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ServerListPingHandler.java index f491219596..12bd4cc9d9 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ServerListPingHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ServerListPingHandler.java @@ -17,7 +17,10 @@ package com.velocitypowered.proxy.connection.util; +import static com.velocityctd.proxy.util.ParsingUtils.parseVariables; + import com.spotify.futures.CompletableFutures; +import com.velocityctd.proxy.cluster.VelocityClusterPlayer; import com.velocitypowered.api.network.ProtocolVersion; import com.velocitypowered.api.proxy.server.PingOptions; import com.velocitypowered.api.proxy.server.ServerPing; @@ -27,12 +30,13 @@ import com.velocitypowered.proxy.config.VelocityConfiguration; import com.velocitypowered.proxy.server.VelocityRegisteredServer; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; +import java.util.concurrent.ThreadLocalRandom; import net.kyori.adventure.text.Component; /** @@ -40,6 +44,8 @@ */ public class ServerListPingHandler { + public static final int SAMPLE_SIZE = 12; + private final VelocityServer server; public ServerListPingHandler(VelocityServer server) { @@ -67,8 +73,8 @@ private ServerPing constructLocalPing(ProtocolVersion clientVersion) { // When forcing a mismatch, prefer displayVersion's protocol so the client still shows an // informative "Client out of date, update to X" label. Fall back to LEGACY (-2) only when - // displayVersion would coincide with the client's protocol (e.g. a client on the proxy's - // actual maximum while maximum-version is configured lower), since that would otherwise + // displayVersion coincides with the client's protocol (e.g., a client on the proxy's + // actual maximum while the maximum-version is configured lower), since that would otherwise // collapse into a normal online state. boolean forceMismatch = configuration.isAlwaysFallBackPing() || clientVersion == ProtocolVersion.UNKNOWN @@ -85,21 +91,7 @@ private ServerPing constructLocalPing(ProtocolVersion clientVersion) { List samplePlayers; if (configuration.getSamplePlayersInPing()) { - List unshuffledPlayers = server.getClusterPlayerService() - .getAllPlayers() - .stream() - .map(player -> { - if (player.isClientListingAllowed()) { - return new ServerPing.SamplePlayer(player.getUsername(), player.getUniqueId()); - } else { - return ServerPing.SamplePlayer.ANONYMOUS; - } - }) - .collect(Collectors.toList()); - - Collections.shuffle(unshuffledPlayers); - int limit = Math.min(12, unshuffledPlayers.size()); - samplePlayers = new ArrayList<>(unshuffledPlayers.subList(0, limit)); + samplePlayers = sampleClusterPlayers(server.getClusterPlayerService().getAllPlayers()); } else { samplePlayers = new ArrayList<>(); } @@ -122,20 +114,22 @@ private ServerPing constructLocalPing(ProtocolVersion clientVersion) { } private String formatVersionString(String raw, ProtocolVersion version) { - String minVersionIntroducedIn = ProtocolVersion.getVersionByName( - server.getConfiguration().getMinimumVersion()).getVersionIntroducedIn(); - String maxVersionDisplay = server.getConfiguration().getMaximumVersion() - .orElse(ProtocolVersion.MAXIMUM_VERSION.getMostRecentSupportedVersion()); - return raw - .replaceAll("\\{protocol-min}", minVersionIntroducedIn) - .replaceAll("\\{protocol-max}", maxVersionDisplay) - .replaceAll("\\{protocol}", version.getVersionIntroducedIn()) - .replaceAll("\\{proxy-brand}", server.getVersion().getName()) - .replaceAll("\\{proxy-brand-custom}", server.getConfiguration().getProxyBrandCustom()) - .replaceAll("\\{proxy-version}", server.getVersion().getVersion()) - .replaceAll("\\{proxy-vendor}", server.getVersion().getVendor()) - .replaceAll("\\{player-count}", String.valueOf(server.getClusterPlayerService().getTotalPlayerCount())) - .replaceAll("\\{max-players}", String.valueOf(server.getConfiguration().getShowMaxPlayers())); + return parseVariables(raw, (variable) -> { + return switch (variable) { + case "protocol-min" -> ProtocolVersion.getVersionByName( + server.getConfiguration().getMinimumVersion()).getVersionIntroducedIn(); + case "protocol-max" -> server.getConfiguration().getMaximumVersion() + .orElse(ProtocolVersion.MAXIMUM_VERSION.getMostRecentSupportedVersion()); + case "protocol" -> version.getVersionIntroducedIn(); + case "proxy-brand" -> server.getVersion().getName(); + case "proxy-brand-custom" -> server.getConfiguration().getProxyBrandCustom(); + case "proxy-version" -> server.getVersion().getVersion(); + case "proxy-vendor" -> server.getVersion().getVendor(); + case "player-count" -> String.valueOf(server.getClusterPlayerService().getTotalPlayerCount()); + case "max-players" -> String.valueOf(server.getConfiguration().getShowMaxPlayers()); + default -> null; + }; + }); } private CompletableFuture attemptPingPassthrough(VelocityInboundConnection connection, @@ -243,4 +237,30 @@ public CompletableFuture getInitialPing(VelocityInboundConnection co shownVersion, fallbackServers.virtualHost()); } } + + /** + * Picks up to {@link #SAMPLE_SIZE} players uniformly at random from {@code players} and maps + * them to {@link ServerPing.SamplePlayer} entries. + */ + private static List sampleClusterPlayers(Collection players) { + List snapshot = new ArrayList<>(players); + int total = snapshot.size(); + + if (total > SAMPLE_SIZE) { + ThreadLocalRandom rng = ThreadLocalRandom.current(); + for (int i = 0; i < SAMPLE_SIZE; i++) { + Collections.swap(snapshot, i, i + rng.nextInt(total - i)); + } + total = SAMPLE_SIZE; + } + + List result = new ArrayList<>(total); + for (int i = 0; i < total; i++) { + VelocityClusterPlayer player = snapshot.get(i); + result.add(player.isClientListingAllowed() + ? new ServerPing.SamplePlayer(player.getUsername(), player.getUniqueId()) + : ServerPing.SamplePlayer.ANONYMOUS); + } + return result; + } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/ProtocolUtils.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/ProtocolUtils.java index 99f1de305a..0f9710109b 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/ProtocolUtils.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/ProtocolUtils.java @@ -917,7 +917,7 @@ public static List newList(int initialCapacity) { * @return pre-sized map */ public static Map newMap(int initialCapacity) { - return new HashMap<>(Math.min(initialCapacity, Short.MAX_VALUE)); + return HashMap.newHashMap(Math.min(initialCapacity, Short.MAX_VALUE)); } /** diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/GameSpyQueryHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/GameSpyQueryHandler.java index 175e88054b..0055fedeef 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/GameSpyQueryHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/GameSpyQueryHandler.java @@ -101,7 +101,7 @@ private QueryResponse createInitialResponse() { .maxPlayers(server.getConfiguration().getShowMaxPlayers()) .proxyPort(server.getConfiguration().getBind().getPort()) .proxyHost(server.getConfiguration().getBind().getHostString()) - .players(server.getAllPlayers().stream().map(ConnectedPlayer::getUsername).collect(Collectors.toList())) + .players(server.getOnlinePlayers().stream().map(ConnectedPlayer::getUsername).collect(Collectors.toList())) .proxyVersion("Velocity-CTD") .plugins(server.getConfiguration().shouldQueryShowPlugins() ? getRealPluginInformation() : Collections.emptyList()) .build(); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/util/PluginMessageUtil.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/util/PluginMessageUtil.java index 6b91119b6d..eef3acc189 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/util/PluginMessageUtil.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/util/PluginMessageUtil.java @@ -19,6 +19,7 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; +import static com.velocityctd.proxy.util.ParsingUtils.parseVariables; import com.google.common.collect.ImmutableList; import com.velocitypowered.api.network.ProtocolVersion; @@ -210,19 +211,23 @@ public static PluginMessagePacket rewriteMinecraftBrand(PluginMessagePacket mess checkNotNull(brand, "brand"); checkArgument(isMcBrand(message), "message is not a brand plugin message"); - String currentBrand = readBrandMessage(message.content()); - String rewrittenBrand = brand - .replaceAll("\\{protocol-min}", minimumVersion) - .replaceAll("\\{protocol-max}", ProtocolVersion.MAXIMUM_VERSION.getMostRecentSupportedVersion()) - .replaceAll("\\{protocol}", ProtocolVersion.MAXIMUM_VERSION.getVersionIntroducedIn()) - .replaceAll("\\{backend-brand}", currentBrand) - .replaceAll("\\{backend-brand-custom}", backendBrandCustom) - .replaceAll("\\{proxy-brand}", version.getName()) - .replaceAll("\\{proxy-brand-custom}", proxyBrandCustom) - .replaceAll("\\{proxy-version}", version.getVersion()) - .replaceAll("\\{proxy-vendor}", version.getVendor()) - .replaceAll("\\{server-connected}", connectedServer) - + "§r"; // Ensures brand coloration remains within bounds + String rewrittenBrand = parseVariables(brand, (variable) -> { + return switch (variable) { + case "protocol-min" -> minimumVersion; + case "protocol-max" -> ProtocolVersion.MAXIMUM_VERSION.getMostRecentSupportedVersion(); + case "protocol" -> ProtocolVersion.MAXIMUM_VERSION.getVersionIntroducedIn(); + case "backend-brand" -> readBrandMessage(message.content()); + case "backend-brand-custom" -> backendBrandCustom; + case "proxy-brand" -> version.getName(); + case "proxy-brand-custom" -> proxyBrandCustom; + case "proxy-version" -> version.getVersion(); + case "proxy-vendor" -> version.getVendor(); + case "server-connected" -> connectedServer; + default -> null; + }; + }); + + rewrittenBrand += "§r"; // Ensures brand coloration remains within bounds ByteBuf rewrittenBuf = Unpooled.buffer(); if (protocolVersion.noLessThan(ProtocolVersion.MINECRAFT_1_8)) {