diff --git a/core/src/main/java/dev/pgm/community/feature/FeatureManager.java b/core/src/main/java/dev/pgm/community/feature/FeatureManager.java index 6742b7fe..a99798d2 100644 --- a/core/src/main/java/dev/pgm/community/feature/FeatureManager.java +++ b/core/src/main/java/dev/pgm/community/feature/FeatureManager.java @@ -24,6 +24,7 @@ import dev.pgm.community.polls.feature.PollFeature; import dev.pgm.community.requests.feature.RequestFeature; import dev.pgm.community.requests.feature.types.SQLRequestFeature; +import dev.pgm.community.serverlinks.ServerLinksFeature; import dev.pgm.community.sessions.feature.SessionFeature; import dev.pgm.community.sessions.feature.types.SQLSessionFeature; import dev.pgm.community.squads.SquadFeature; @@ -60,6 +61,7 @@ public class FeatureManager { private final PollFeature polls; private final SquadFeature squads; private final MatchHistoryFeature history; + private final ServerLinksFeature serverLinks; public FeatureManager( Configuration config, @@ -97,6 +99,7 @@ public FeatureManager( this.polls = new PollFeature(config, logger); this.squads = new SquadFeature(config, logger); this.history = new MatchHistoryFeature(config, logger); + this.serverLinks = new ServerLinksFeature(config, logger); } public AssistanceFeature getReports() { @@ -179,6 +182,10 @@ public MatchHistoryFeature getHistory() { return history; } + public ServerLinksFeature getServerLinks() { + return serverLinks; + } + public void reloadConfig(Configuration config) { // Reload all config values here getReports().getConfig().reload(config); @@ -200,6 +207,7 @@ public void reloadConfig(Configuration config) { getPolls().getConfig().reload(config); getSquads().getConfig().reload(config); getHistory().getConfig().reload(config); + getServerLinks().getConfig().reload(config); // TODO: Look into maybe unregister commands for features that have been disabled // commands#unregisterCommand @@ -226,5 +234,6 @@ public void disable() { if (getPolls().isEnabled()) getPolls().disable(); if (getSquads().isEnabled()) getSquads().disable(); if (getHistory().isEnabled()) getHistory().disable(); + if (getServerLinks().isEnabled()) getServerLinks().disable(); } } diff --git a/core/src/main/java/dev/pgm/community/serverlinks/ServerLinksConfig.java b/core/src/main/java/dev/pgm/community/serverlinks/ServerLinksConfig.java new file mode 100644 index 00000000..55d25ca3 --- /dev/null +++ b/core/src/main/java/dev/pgm/community/serverlinks/ServerLinksConfig.java @@ -0,0 +1,62 @@ +package dev.pgm.community.serverlinks; + +import static tc.oc.pgm.util.text.TextParser.parseComponent; +import static tc.oc.pgm.util.text.TextParser.parseEnum; +import static tc.oc.pgm.util.text.TextParser.parseUri; + +import dev.pgm.community.feature.config.FeatureConfigImpl; +import dev.pgm.community.serverlinks.types.ServerLink; +import dev.pgm.community.serverlinks.types.ServerLinkBuiltinType; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.bukkit.configuration.Configuration; + +public class ServerLinksConfig extends FeatureConfigImpl { + private static final String KEY = "server-links"; + private static final String LINKS_KEY = "links"; + + private static final String LINK_BUILTIN_KEY = "builtin"; + private static final String LINK_CUSTOM_TEXT_KEY = "text"; + private static final String LINK_URI_KEY = "uri"; + + private List links; + + public ServerLinksConfig(Configuration config) { + super(KEY, config); + } + + public List getLinks() { + return links; + } + + @Override + public void reload(Configuration config) { + super.reload(config); + links = config.getMapList(getKey() + "." + LINKS_KEY).stream() + .map(this::readLink) + .toList(); + } + + private ServerLink readLink(Map configData) { + String builtIn = Objects.toString(configData.get(LINK_BUILTIN_KEY), null); + String customText = Objects.toString(configData.get(LINK_CUSTOM_TEXT_KEY), null); + String uri = Objects.toString(configData.get(LINK_URI_KEY), null); + + if ((builtIn == null) == (customText == null)) { + throw new IllegalStateException( + "A server link must have either built-in or custom text defined"); + } + + URI parsedUri = parseUri(uri); + if (!parsedUri.getScheme().equals("http") && !parsedUri.getScheme().equals("https")) { + throw new IllegalStateException("The URL " + uri + " is not a web URL"); + } + + return new ServerLink( + builtIn != null ? parseEnum(builtIn, ServerLinkBuiltinType.class) : null, + customText != null ? parseComponent(customText) : null, + parsedUri); + } +} diff --git a/core/src/main/java/dev/pgm/community/serverlinks/ServerLinksFeature.java b/core/src/main/java/dev/pgm/community/serverlinks/ServerLinksFeature.java new file mode 100644 index 00000000..986a1273 --- /dev/null +++ b/core/src/main/java/dev/pgm/community/serverlinks/ServerLinksFeature.java @@ -0,0 +1,44 @@ +package dev.pgm.community.serverlinks; + +import dev.pgm.community.feature.FeatureBase; +import dev.pgm.community.serverlinks.types.ServerLink; +import dev.pgm.community.util.Platform; +import java.util.List; +import java.util.logging.Logger; +import org.bukkit.configuration.Configuration; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.player.PlayerJoinEvent; + +public class ServerLinksFeature extends FeatureBase { + private static final ServerLinksPlatform PLATFORM = Platform.get(ServerLinksPlatform.class); + + public interface ServerLinksPlatform { + default boolean isSupported() { + return true; + } + + void sendToPlayer(Player player, List serverLinks); + } + + public ServerLinksFeature(Configuration config, Logger logger) { + super(new ServerLinksConfig(config), logger, "Server Links"); + + if (getConfig().isEnabled()) { + if (!PLATFORM.isSupported()) { + logger.warning("Server links are enabled but not supported by the platform"); + return; + } + enable(); + } + } + + @EventHandler + public void onPlayerJoin(PlayerJoinEvent event) { + PLATFORM.sendToPlayer(event.getPlayer(), getServerLinksConfig().getLinks()); + } + + public ServerLinksConfig getServerLinksConfig() { + return (ServerLinksConfig) getConfig(); + } +} diff --git a/core/src/main/java/dev/pgm/community/serverlinks/types/ServerLink.java b/core/src/main/java/dev/pgm/community/serverlinks/types/ServerLink.java new file mode 100644 index 00000000..0f2ed101 --- /dev/null +++ b/core/src/main/java/dev/pgm/community/serverlinks/types/ServerLink.java @@ -0,0 +1,13 @@ +package dev.pgm.community.serverlinks.types; + +import java.net.URI; +import net.kyori.adventure.text.Component; + +/** + * Represents a Minecraft server link. + * + * @param builtinType The built-in type of the server link, or null if it's a custom link. + * @param customText The custom text for the server link, or null if builtinType is set. + * @param uri The URI of the server link. + */ +public record ServerLink(ServerLinkBuiltinType builtinType, Component customText, URI uri) {} diff --git a/core/src/main/java/dev/pgm/community/serverlinks/types/ServerLinkBuiltinType.java b/core/src/main/java/dev/pgm/community/serverlinks/types/ServerLinkBuiltinType.java new file mode 100644 index 00000000..6998f234 --- /dev/null +++ b/core/src/main/java/dev/pgm/community/serverlinks/types/ServerLinkBuiltinType.java @@ -0,0 +1,18 @@ +package dev.pgm.community.serverlinks.types; + +/** + * Represents a built-in server link type that will be auto-translated by the Minecraft client and + * possibly have special functionality. Keep in sync with Paper's org.bukkit.ServerLinks.Type. + */ +public enum ServerLinkBuiltinType { + REPORT_BUG, + COMMUNITY_GUIDELINES, + SUPPORT, + STATUS, + FEEDBACK, + COMMUNITY, + WEBSITE, + FORUMS, + NEWS, + ANNOUNCEMENTS; +} diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index 06fc6291..05b0b4e7 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -385,3 +385,15 @@ database: host: "localhost:3306" # host and port timezone: "America/Los_Angeles" # Database timezone max-connections: 2 # Maximum simultaneous connections (does not impact SQLite) + +# Server Links - Adds links to the pause menu for 1.21+ clients +# Requires ViaVersion to be installed on 1.8-based servers. +server-links: + enabled: false + links: + # A built-in server link type will be auto-translated by the client and may provide some extra functionality. + - builtin: report bug # See https://jd.papermc.io/paper/org/bukkit/ServerLinks.Type.html for a list of built-in types + uri: https://pgm.dev + # Alternatively, custom text can be provided. Custom text is mutually exclusive with built-in types. + - text: "Submit a new map" + uri: https://pgm.dev diff --git a/core/src/main/resources/plugin.yml b/core/src/main/resources/plugin.yml index 385983d4..214d1976 100644 --- a/core/src/main/resources/plugin.yml +++ b/core/src/main/resources/plugin.yml @@ -5,4 +5,4 @@ main: ${mainClass} version: ${version} (git-${commitHash}) website: ${url} author: ${author} -softdepend: [PGM, Environment] +softdepend: [PGM, Environment, ViaVersion] diff --git a/platform/platform-modern/src/main/java/dev/pgm/community/platform/modern/feature/serverlinks/ModernServerLinksPlatform.java b/platform/platform-modern/src/main/java/dev/pgm/community/platform/modern/feature/serverlinks/ModernServerLinksPlatform.java new file mode 100644 index 00000000..20574b71 --- /dev/null +++ b/platform/platform-modern/src/main/java/dev/pgm/community/platform/modern/feature/serverlinks/ModernServerLinksPlatform.java @@ -0,0 +1,37 @@ +package dev.pgm.community.platform.modern.feature.serverlinks; + +import static dev.pgm.community.util.Supports.Variant.PAPER; + +import dev.pgm.community.serverlinks.ServerLinksFeature; +import dev.pgm.community.serverlinks.types.ServerLink; +import dev.pgm.community.serverlinks.types.ServerLinkBuiltinType; +import dev.pgm.community.util.Supports; +import java.util.List; +import org.bukkit.ServerLinks; +import org.bukkit.craftbukkit.CraftServerLinks; +import org.bukkit.entity.Player; + +@Supports(PAPER) +public class ModernServerLinksPlatform implements ServerLinksFeature.ServerLinksPlatform { + @Override + public void sendToPlayer(Player player, List serverLinks) { + player.sendLinks(toPlatformServerLinks(serverLinks)); + } + + private ServerLinks toPlatformServerLinks(List links) { + ServerLinks bukkitLinks = new CraftServerLinks(new net.minecraft.server.ServerLinks(List.of())); + for (ServerLink link : links) { + if (link.builtinType() != null) { + bukkitLinks.addLink(toBukkitType(link.builtinType()), link.uri()); + } else { + bukkitLinks.addLink(link.customText(), link.uri()); + } + } + + return bukkitLinks; + } + + private ServerLinks.Type toBukkitType(ServerLinkBuiltinType type) { + return ServerLinks.Type.values()[type.ordinal()]; + } +} diff --git a/platform/platform-sportpaper/build.gradle.kts b/platform/platform-sportpaper/build.gradle.kts index a1d44a94..df0a107f 100644 --- a/platform/platform-sportpaper/build.gradle.kts +++ b/platform/platform-sportpaper/build.gradle.kts @@ -2,8 +2,13 @@ plugins { id("buildlogic.java-conventions") } +repositories { + maven("https://repo.viaversion.com") // ViaVersion +} + dependencies { implementation(project(":core")) implementation(project(":util")) compileOnly("app.ashcon:sportpaper:1.8.8-R0.1-SNAPSHOT") + compileOnly("com.viaversion:viaversion-api:5.0.0") } diff --git a/platform/platform-sportpaper/src/main/java/dev/pgm/community/platform/sportpaper/features/serverlinks/SpServerLinksPlatform.java b/platform/platform-sportpaper/src/main/java/dev/pgm/community/platform/sportpaper/features/serverlinks/SpServerLinksPlatform.java new file mode 100644 index 00000000..5125ac3f --- /dev/null +++ b/platform/platform-sportpaper/src/main/java/dev/pgm/community/platform/sportpaper/features/serverlinks/SpServerLinksPlatform.java @@ -0,0 +1,35 @@ +package dev.pgm.community.platform.sportpaper.features.serverlinks; + +import static dev.pgm.community.util.Supports.Variant.SPORTPAPER; + +import dev.pgm.community.serverlinks.ServerLinksFeature; +import dev.pgm.community.serverlinks.types.ServerLink; +import dev.pgm.community.util.Supports; +import java.util.List; +import org.bukkit.entity.Player; + +@Supports(SPORTPAPER) +public class SpServerLinksPlatform implements ServerLinksFeature.ServerLinksPlatform { + private static final boolean HAS_VIA = hasVia(); + + private static boolean hasVia() { + try { + Class.forName("com.viaversion.viaversion.api.Via"); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + + @Override + public boolean isSupported() { + return HAS_VIA; + } + + @Override + public void sendToPlayer(Player player, List serverLinks) { + if (HAS_VIA) { + ViaServerLinks.sendToPlayer(player, serverLinks); + } + } +} diff --git a/platform/platform-sportpaper/src/main/java/dev/pgm/community/platform/sportpaper/features/serverlinks/ViaServerLinks.java b/platform/platform-sportpaper/src/main/java/dev/pgm/community/platform/sportpaper/features/serverlinks/ViaServerLinks.java new file mode 100644 index 00000000..384b3b53 --- /dev/null +++ b/platform/platform-sportpaper/src/main/java/dev/pgm/community/platform/sportpaper/features/serverlinks/ViaServerLinks.java @@ -0,0 +1,64 @@ +package dev.pgm.community.platform.sportpaper.features.serverlinks; + +import com.viaversion.nbt.tag.Tag; +import com.viaversion.viaversion.api.Via; +import com.viaversion.viaversion.api.connection.UserConnection; +import com.viaversion.viaversion.api.protocol.Protocol; +import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; +import com.viaversion.viaversion.api.protocol.packet.State; +import com.viaversion.viaversion.api.protocol.version.ProtocolVersion; +import com.viaversion.viaversion.api.type.Types; +import com.viaversion.viaversion.libs.gson.JsonParser; +import com.viaversion.viaversion.libs.mcstructs.text.utils.JsonNbtConverter; +import dev.pgm.community.serverlinks.types.ServerLink; +import java.util.List; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; +import org.bukkit.entity.Player; + +public class ViaServerLinks { + private static final Protocol serverLinkProtocol = findServerLinkProtocol(); + + public static void sendToPlayer(Player player, List serverLinks) { + if (!Via.getAPI().isInjected(player.getUniqueId())) return; + UserConnection userConnection = Via.getAPI().getConnection(player.getUniqueId()); + if (userConnection != null + && userConnection + .getProtocolInfo() + .protocolVersion() + .newerThanOrEqualTo(ProtocolVersion.v1_21)) { + PacketWrapper serverLinksPacket = createPacket(userConnection, serverLinks); + serverLinksPacket.scheduleSend(serverLinkProtocol.getClass()); + } + } + + private static PacketWrapper createPacket(UserConnection conn, List links) { + var packetTypes = serverLinkProtocol.getPacketTypesProvider().mappedClientboundPacketTypes(); + var packetType = packetTypes.get(State.PLAY).typeByName("SERVER_LINKS"); + PacketWrapper packet = PacketWrapper.create(packetType, conn); + packet.write(Types.VAR_INT, links.size()); + // TODO: is there a better way to do this? + for (ServerLink link : links) { + packet.write(Types.BOOLEAN, link.builtinType() != null); + if (link.builtinType() != null) { + packet.write(Types.VAR_INT, link.builtinType().ordinal()); + } else { + packet.write(Types.TAG, toViaTag(link.customText())); + } + packet.write(Types.STRING, link.uri().toString()); + } + + return packet; + } + + private static Tag toViaTag(Component component) { + return JsonNbtConverter.toNbt( + JsonParser.parseString(GsonComponentSerializer.gson().serialize(component))); + } + + private static Protocol findServerLinkProtocol() { + return Via.getManager() + .getProtocolManager() + .getProtocol(/* to */ ProtocolVersion.v1_21, /* from */ ProtocolVersion.v1_20_5); + } +}