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 bf12356e..8869b1e7 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.RequestFeatureCore; +import dev.pgm.community.serverlinks.ServerLinksFeature; import dev.pgm.community.sessions.feature.SessionFeature; import dev.pgm.community.sessions.feature.types.SessionFeatureCore; import dev.pgm.community.squads.SquadFeature; @@ -63,6 +64,7 @@ public class FeatureManager { private final PollFeature polls; private final SquadFeature squads; private final MatchHistoryFeature history; + private final ServerLinksFeature serverLinks; public FeatureManager(Configuration config, Logger logger, InventoryManager inventory) { // Networking @@ -99,6 +101,7 @@ public FeatureManager(Configuration config, Logger logger, InventoryManager inve 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() { @@ -185,6 +188,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); @@ -207,6 +214,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 @@ -234,5 +242,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..058c1ac6 --- /dev/null +++ b/core/src/main/java/dev/pgm/community/serverlinks/ServerLinksConfig.java @@ -0,0 +1,67 @@ +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; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; + +@NotNullByDefault +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 @Nullable @Unmodifiable List links; + + public ServerLinksConfig(Configuration config) { + super(KEY, config); + } + + public @Unmodifiable List getLinks() { + assert links != null; + 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..7d19cc99 --- /dev/null +++ b/core/src/main/java/dev/pgm/community/serverlinks/ServerLinksFeature.java @@ -0,0 +1,46 @@ +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; +import org.jetbrains.annotations.NotNullByDefault; + +@NotNullByDefault +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..a8271a50 --- /dev/null +++ b/core/src/main/java/dev/pgm/community/serverlinks/types/ServerLink.java @@ -0,0 +1,17 @@ +package dev.pgm.community.serverlinks.types; + +import java.net.URI; +import net.kyori.adventure.text.Component; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +/** + * 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. + */ +@NotNullByDefault +public record ServerLink( + @Nullable ServerLinkBuiltinType builtinType, @Nullable 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..11450aa9 --- /dev/null +++ b/core/src/main/java/dev/pgm/community/serverlinks/types/ServerLinkBuiltinType.java @@ -0,0 +1,21 @@ +package dev.pgm.community.serverlinks.types; + +import org.jetbrains.annotations.NotNullByDefault; + +/** + * 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. + */ +@NotNullByDefault +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 67d0d732..9d659e1a 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -240,7 +240,7 @@ command-audit: - "/sudo" - "/community:sudo" -# Freeze - Freeze players via command or observer hotbar tool (when PGM is enabled) +# Freeze - Freeze players via command or observer hotbar tool (when PGM is enabled) freeze: enabled: true pgm-integration: true # Whether Community will attempt to hook into PGM @@ -404,3 +404,15 @@ database: sqlite: # Relative paths resolve under the plugin data folder. file: "community.db" # SQLite file name or absolute path + +# 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 b15c74c2..cf37f6b8 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: [Database, PGM, Environment] +softdepend: [Database, 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..ee1ad55c --- /dev/null +++ b/platform/platform-modern/src/main/java/dev/pgm/community/platform/modern/feature/serverlinks/ModernServerLinksPlatform.java @@ -0,0 +1,40 @@ +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; +import org.jetbrains.annotations.NotNullByDefault; + +@Supports(PAPER) +@NotNullByDefault +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 { + assert link.customText() != null; + 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..b7aa903e --- /dev/null +++ b/platform/platform-sportpaper/src/main/java/dev/pgm/community/platform/sportpaper/features/serverlinks/SpServerLinksPlatform.java @@ -0,0 +1,37 @@ +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; +import org.jetbrains.annotations.NotNullByDefault; + +@Supports(SPORTPAPER) +@NotNullByDefault +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..86fe9b5d --- /dev/null +++ b/platform/platform-sportpaper/src/main/java/dev/pgm/community/platform/sportpaper/features/serverlinks/ViaServerLinks.java @@ -0,0 +1,72 @@ +package dev.pgm.community.platform.sportpaper.features.serverlinks; + +import static tc.oc.pgm.util.Assert.assertNotNull; + +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; +import org.jetbrains.annotations.NotNullByDefault; + +@NotNullByDefault +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()); + for (ServerLink link : links) { + packet.write(Types.BOOLEAN, link.builtinType() != null); + if (link.builtinType() != null) { + packet.write(Types.VAR_INT, link.builtinType().ordinal()); + } else { + assert link.customText() != null; + packet.write(Types.TAG, toViaTag(link.customText())); + } + packet.write(Types.STRING, link.uri().toString()); + } + + return packet; + } + + private static Tag toViaTag(Component component) { + return assertNotNull( + JsonNbtConverter.toNbt( + JsonParser.parseString(GsonComponentSerializer.gson().serialize(component))), + "Component -> NBT conversion failed"); + } + + private static Protocol findServerLinkProtocol() { + return assertNotNull( + Via.getManager() + .getProtocolManager() + .getProtocol(/* to */ ProtocolVersion.v1_21, /* from */ ProtocolVersion.v1_20_5), + "ViaVersion v1.20.5 -> v1.21 protocol was not found"); + } +}