diff --git a/bin/hub/build.gradle.kts b/bin/hub/build.gradle.kts index 9b9065a42..b38b80ad6 100644 --- a/bin/hub/build.gradle.kts +++ b/bin/hub/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { implementation(project(":modules:map-core")) implementation(project(":modules:map-runtime")) implementation(project(":modules:terraform")) + implementation(project(":modules:compat")) implementation(libs.minestom) implementation(libs.bundles.adventure) diff --git a/bin/hub/src/main/java/net/hollowcube/mapmaker/hub/HubPlayerState.java b/bin/hub/src/main/java/net/hollowcube/mapmaker/hub/HubPlayerState.java index f5130b239..5598162d8 100644 --- a/bin/hub/src/main/java/net/hollowcube/mapmaker/hub/HubPlayerState.java +++ b/bin/hub/src/main/java/net/hollowcube/mapmaker/hub/HubPlayerState.java @@ -2,6 +2,7 @@ import net.hollowcube.common.util.FontUtil; import net.hollowcube.common.util.FutureUtil; +import net.hollowcube.compat.api.discord.DiscordRichPresenceManager; import net.hollowcube.mapmaker.PlayerSettings; import net.hollowcube.mapmaker.hub.feature.event.christmas.AdventCalendarItem; import net.hollowcube.mapmaker.hub.feature.misc.DoubleJumpFeature; @@ -61,6 +62,8 @@ public void configurePlayer(HubMapWorld world, Player player, @Nullable HubPlaye if (player instanceof MapPlayer mp) { mp.setCanSendPose(false); } + + DiscordRichPresenceManager.queueRichPresenceUpdate(player, "In the", "lobby", "play.hollowcube.net"); } @Override diff --git a/build-src/src/main/kotlin/mapmaker.java-binary.gradle.kts b/build-src/src/main/kotlin/mapmaker.java-binary.gradle.kts index b5c864edb..fc909cba1 100644 --- a/build-src/src/main/kotlin/mapmaker.java-binary.gradle.kts +++ b/build-src/src/main/kotlin/mapmaker.java-binary.gradle.kts @@ -29,6 +29,14 @@ repositories { includeGroup("com.noxcrew.noxesium") } } + + maven(url = "https://repo.feathermc.net/artifactory/maven-releases") { + content { + includeGroup("net.digitalingot.feather-server-api") + } + } + + maven(url = "https://repo.lunarclient.dev") } dependencies { diff --git a/build-src/src/main/kotlin/mapmaker.java-library.gradle.kts b/build-src/src/main/kotlin/mapmaker.java-library.gradle.kts index e2ed59006..492a7cf68 100644 --- a/build-src/src/main/kotlin/mapmaker.java-library.gradle.kts +++ b/build-src/src/main/kotlin/mapmaker.java-library.gradle.kts @@ -25,6 +25,14 @@ repositories { includeGroup("com.noxcrew.noxesium") } } + + maven(url = "https://repo.feathermc.net/artifactory/maven-releases") { + content { + includeGroup("net.digitalingot.feather-server-api") + } + } + + maven(url = "https://repo.lunarclient.dev") } dependencies { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bfe2a09ca..0b3a6deba 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,6 +43,8 @@ slf4j = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } slf4j-jul = { group = "org.slf4j", name = "jul-to-slf4j", version.ref = "slf4j" } logback = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" } noxesium = { group = "com.noxcrew.noxesium", name = "api", version.ref = "noxesium" } +feather = { group = "net.digitalingot.feather-server-api", name = "messaging", version = "0.0.5" } +apollo = { group = "com.lunarclient", name = "apollo-protos", version = "0.0.6" } zstd = { group = "com.github.luben", name = "zstd-jni", version.ref = "zstd" } polar = { group = "dev.hollowcube", name = "polar", version.ref = "polar" } similarity = { group = "info.debatty", name = "java-string-similarity", version.ref = "similarity" } diff --git a/modules/common/src/main/java/net/hollowcube/common/util/StringUtil.java b/modules/common/src/main/java/net/hollowcube/common/util/StringUtil.java new file mode 100644 index 000000000..3476fbca6 --- /dev/null +++ b/modules/common/src/main/java/net/hollowcube/common/util/StringUtil.java @@ -0,0 +1,12 @@ +package net.hollowcube.common.util; + +public class StringUtil { + public static String indefiniteArticle(final String word) { + // building -> a building + // adventure -> an adventure + return switch (word.charAt(0)) { + case 'a', 'e', 'i', 'o', 'u' -> "an"; + default -> "a"; + } + " " + word; + } +} diff --git a/modules/compat/build.gradle.kts b/modules/compat/build.gradle.kts index 6097829a1..712fe6091 100644 --- a/modules/compat/build.gradle.kts +++ b/modules/compat/build.gradle.kts @@ -10,4 +10,6 @@ dependencies { implementation(libs.fastutil) implementation(libs.posthog) implementation(libs.zstd) + implementation(libs.feather) + implementation(libs.caffeine) } diff --git a/modules/compat/src/main/java/net/hollowcube/compat/api/discord/DiscordRichPresenceManager.java b/modules/compat/src/main/java/net/hollowcube/compat/api/discord/DiscordRichPresenceManager.java new file mode 100644 index 000000000..e2c949d02 --- /dev/null +++ b/modules/compat/src/main/java/net/hollowcube/compat/api/discord/DiscordRichPresenceManager.java @@ -0,0 +1,44 @@ +package net.hollowcube.compat.api.discord; + +import net.minestom.server.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.Duration; +import java.util.HashSet; +import java.util.ServiceLoader; +import java.util.Set; + + +public class DiscordRichPresenceManager { + private static final Set PROVIDERS = new HashSet<>(); + + static { + ServiceLoader.load(DiscordRichPresenceProvider.class).forEach(PROVIDERS::add); + } + + + public static void queueRichPresenceUpdate( + @NotNull Player player, + @NotNull String activity, @NotNull String name, + @Nullable String details + ) { + player.scheduler().buildTask(() -> { + for (DiscordRichPresenceProvider provider : PROVIDERS) { + if (provider.isRichPresenceSupportedFor(player)) { + provider.setRichPresence(player, activity, name, details); + break; + } + } + }).delay(Duration.ofMillis(2500)).schedule(); + } + + public static void clearRichPresence(@NotNull Player player) { + for (DiscordRichPresenceProvider provider : PROVIDERS) { + if (provider.isRichPresenceSupportedFor(player)) { + provider.clearRichPresence(player); + return; + } + } + } +} diff --git a/modules/compat/src/main/java/net/hollowcube/compat/api/discord/DiscordRichPresenceProvider.java b/modules/compat/src/main/java/net/hollowcube/compat/api/discord/DiscordRichPresenceProvider.java new file mode 100644 index 000000000..0e8aaa4bb --- /dev/null +++ b/modules/compat/src/main/java/net/hollowcube/compat/api/discord/DiscordRichPresenceProvider.java @@ -0,0 +1,24 @@ +package net.hollowcube.compat.api.discord; + +import net.minestom.server.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public interface DiscordRichPresenceProvider { + + /** + * Sets the rich presence for a player. + * Format: + * 0: " on Hollow Cube" + * 1: "" If details is null or empty, this line will default to whaterver the provide wants, on lunar this is the mc version, on feather nothing. + */ + void setRichPresence( + @NotNull Player player, + @NotNull String activity, @NotNull String name, + @Nullable String details + ); + + void clearRichPresence(@NotNull Player player); + + boolean isRichPresenceSupportedFor(@NotNull Player player); +} diff --git a/modules/compat/src/main/java/net/hollowcube/compat/api/packet/ServerboundModPacket.java b/modules/compat/src/main/java/net/hollowcube/compat/api/packet/ServerboundModPacket.java index 32d1ef7d1..ecb2f3a3e 100644 --- a/modules/compat/src/main/java/net/hollowcube/compat/api/packet/ServerboundModPacket.java +++ b/modules/compat/src/main/java/net/hollowcube/compat/api/packet/ServerboundModPacket.java @@ -1,6 +1,7 @@ package net.hollowcube.compat.api.packet; import net.minestom.server.network.NetworkBuffer; +import net.minestom.server.utils.ThrowingFunction; import org.jetbrains.annotations.NotNull; import java.util.function.Function; @@ -15,7 +16,7 @@ public static > Type of(String namespace, S return new Type<>("%s:%s".formatted(namespace, path), codec); } - public static > ServerboundModPacket.Type of(String namespace, String path, Function reader) { + public static > ServerboundModPacket.Type of(String namespace, String path, ThrowingFunction reader) { var type = new NetworkBuffer.Type() { @Override public void write(@NotNull NetworkBuffer buffer, T value) { @@ -24,7 +25,11 @@ public void write(@NotNull NetworkBuffer buffer, T value) { @Override public T read(@NotNull NetworkBuffer buffer) { - return reader.apply(buffer); + try { + return reader.apply(buffer); + } catch (Exception e) { + throw new RuntimeException(e); + } } }; return new ServerboundModPacket.Type<>("%s:%s".formatted(namespace, path), type); diff --git a/modules/compat/src/main/java/net/hollowcube/compat/feather/FeatherCompatProvider.java b/modules/compat/src/main/java/net/hollowcube/compat/feather/FeatherCompatProvider.java new file mode 100644 index 000000000..d909d9e6c --- /dev/null +++ b/modules/compat/src/main/java/net/hollowcube/compat/feather/FeatherCompatProvider.java @@ -0,0 +1,157 @@ +package net.hollowcube.compat.feather; + +import com.google.auto.service.AutoService; +import net.digitalingot.feather.serverapi.messaging.*; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CClearDiscordActivity; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CHandshake; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CSetDiscordActivity; +import net.digitalingot.feather.serverapi.messaging.messages.server.C2SClientHello; +import net.digitalingot.feather.serverapi.messaging.messages.server.C2SHandshake; +import net.hollowcube.compat.api.CompatProvider; +import net.hollowcube.compat.api.discord.DiscordRichPresenceProvider; +import net.hollowcube.compat.api.packet.PacketRegistry; +import net.hollowcube.compat.feather.packets.ClientboundFeatherPacket; +import net.hollowcube.compat.feather.packets.ServerboundFeatherPacket; +import net.minestom.server.entity.Player; +import net.minestom.server.event.GlobalEventHandler; +import net.minestom.server.event.player.PlayerDisconnectEvent; +import net.minestom.server.tag.Tag; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@AutoService({CompatProvider.class, DiscordRichPresenceProvider.class}) +public class FeatherCompatProvider implements CompatProvider, DiscordRichPresenceProvider { + private static final String IMAGE_URL = "https://servermappings.lunarclientcdn.com/logos/hollowcube.png"; + private static final Tag FEATHER_SUPPORT_ENABLED = Tag.Transient("mapmaker:feather/enabled"); + private static final Handshaking HANDSHAKING = new Handshaking(); + + @Override + public void registerListeners(GlobalEventHandler events) { + events.addListener(PlayerDisconnectEvent.class, e -> HANDSHAKING.finish(e.getPlayer())); + + } + + @Override + public void registerPackets(PacketRegistry registry) { + registry.register(ClientboundFeatherPacket.TYPE); + registry.register(ServerboundFeatherPacket.TYPE, (player, packet) -> { + if (!player.hasTag(FEATHER_SUPPORT_ENABLED)) { + final var hello = HANDSHAKING.handle(player, packet.message()); + + if (hello != null) { + player.setTag(FEATHER_SUPPORT_ENABLED, true); + } + + } + }); + } + + @Override + public void setRichPresence( + @NotNull Player player, + @NotNull String activity, @NotNull String name, + @Nullable String details + ) { + new ClientboundFeatherPacket( + new S2CSetDiscordActivity( + IMAGE_URL, + "Hollow Cube", + details, + "%s %s on Hollow Cube".formatted(activity, name), + null, + null, + null, + null + ) + ).send(player); + } + + @Override + public void clearRichPresence(@NotNull Player player) { + new ClientboundFeatherPacket(new S2CClearDiscordActivity()).send(player); + } + + @Override + public boolean isRichPresenceSupportedFor(@NotNull Player player) { + return player.hasTag(FEATHER_SUPPORT_ENABLED); + } + + // This is directly copied from https://github.com/FeatherMC/feather-server-api/blob/main/bukkit/src/main/java/net/digitalingot/feather/serverapi/bukkit/messaging/BukkitMessagingService.java + // but with some modifications to make it work with Minestom + private static class Handshaking { + private final Map handshakes = new HashMap<>(); + + + private HandshakeState getState(Player player) { + return this.handshakes.getOrDefault(player.getUuid(), HandshakeState.EXPECTING_HANDSHAKE); + } + + private void setState(UUID playerId, HandshakeState state) { + this.handshakes.put(playerId, state); + } + + private void accept(Player player) { + setState(player.getUuid(), HandshakeState.EXPECTING_HELLO); + new ClientboundFeatherPacket(new S2CHandshake()).send(player); + } + + private void reject(Player player) { + setState(player.getUuid(), HandshakeState.REJECTED); + } + + private void finish(Player player) { + this.handshakes.remove(player.getUuid()); + } + + private C2SClientHello handle(Player player, Message message) { + HandshakeState state = getState(player); + + if (state == HandshakeState.REJECTED) { + return null; + } + + + if (state == HandshakeState.EXPECTING_HANDSHAKE) { + if (handleExpectingHandshake(message)) { + accept(player); + } else { + reject(player); + } + } else if (state == HandshakeState.EXPECTING_HELLO) { + if ((message instanceof C2SClientHello)) { + finish(player); + return (C2SClientHello) message; + } + reject(player); + } + + return null; + } + + private boolean handleExpectingHandshake(Message message) { + if (!(message instanceof C2SHandshake handshake)) { + return false; + } + int protocolVersion = handshake.getProtocolVersion(); + if (protocolVersion > MessageConstants.VERSION) { + // In the official API Implementation, a mismatched API version just alerts players with a permission that it is out of date. + // It still processes packets fine. + // There is no indication of what versioning compatibility we can expect since they've only released one version. + // For now, we can probably just ignore this. + } + return true; + } + + + private enum HandshakeState { + EXPECTING_HANDSHAKE, + EXPECTING_HELLO, + REJECTED + } + } + +} diff --git a/modules/compat/src/main/java/net/hollowcube/compat/feather/packets/ClientboundFeatherPacket.java b/modules/compat/src/main/java/net/hollowcube/compat/feather/packets/ClientboundFeatherPacket.java new file mode 100644 index 000000000..71430dd6c --- /dev/null +++ b/modules/compat/src/main/java/net/hollowcube/compat/feather/packets/ClientboundFeatherPacket.java @@ -0,0 +1,35 @@ +package net.hollowcube.compat.feather.packets; + +import net.digitalingot.feather.serverapi.messaging.ClientMessageHandler; +import net.digitalingot.feather.serverapi.messaging.Message; +import net.digitalingot.feather.serverapi.messaging.MessageDecoder; +import net.digitalingot.feather.serverapi.messaging.MessageEncoder; +import net.hollowcube.compat.api.packet.ClientboundModPacket; +import net.minestom.server.network.NetworkBuffer; +import org.jetbrains.annotations.NotNull; + +// Feather supports a fragmented packet channel for messages that exceed the packet size limit, which was set lower on older versions of Bukkit +// We don't ever send messages that get that big, so for now this can be ignored. +// If we ever utilise more of their UI features, this may be needed in the future. +public record ClientboundFeatherPacket( + @NotNull Message message +) implements ClientboundModPacket { + public static final Type TYPE = Type.of( + "feather", + "client", + NetworkBuffer.RAW_BYTES.transform(ClientboundFeatherPacket::new, ClientboundFeatherPacket::toBytes) + ); + + private ClientboundFeatherPacket(byte[] bytes) { + this(MessageDecoder.CLIENT_BOUND.decode(bytes)); + } + + public byte[] toBytes() { + return MessageEncoder.CLIENT_BOUND.encode(message); + } + + @Override + public Type getType() { + return TYPE; + } +} diff --git a/modules/compat/src/main/java/net/hollowcube/compat/feather/packets/ServerboundFeatherPacket.java b/modules/compat/src/main/java/net/hollowcube/compat/feather/packets/ServerboundFeatherPacket.java new file mode 100644 index 000000000..8c66a83d7 --- /dev/null +++ b/modules/compat/src/main/java/net/hollowcube/compat/feather/packets/ServerboundFeatherPacket.java @@ -0,0 +1,32 @@ +package net.hollowcube.compat.feather.packets; + +import net.digitalingot.feather.serverapi.messaging.Message; +import net.digitalingot.feather.serverapi.messaging.MessageDecoder; +import net.digitalingot.feather.serverapi.messaging.MessageEncoder; +import net.digitalingot.feather.serverapi.messaging.ServerMessageHandler; +import net.hollowcube.compat.api.packet.ServerboundModPacket; +import net.minestom.server.network.NetworkBuffer; +import org.jetbrains.annotations.NotNull; + +public record ServerboundFeatherPacket( + @NotNull Message message +) implements ServerboundModPacket { + public static final Type TYPE = Type.of( + "feather", + "client", + NetworkBuffer.RAW_BYTES.transform(ServerboundFeatherPacket::new, ServerboundFeatherPacket::toBytes) + ); + + private ServerboundFeatherPacket(byte[] bytes) { + this(MessageDecoder.SERVER_BOUND.decode(bytes)); + } + + public byte[] toBytes() { + return MessageEncoder.SERVER_BOUND.encode(message); + } + + @Override + public Type getType() { + return TYPE; + } +} diff --git a/modules/compat/src/main/java/net/hollowcube/compat/lunar/LunarCompatProvider.java b/modules/compat/src/main/java/net/hollowcube/compat/lunar/LunarCompatProvider.java new file mode 100644 index 000000000..12874a93e --- /dev/null +++ b/modules/compat/src/main/java/net/hollowcube/compat/lunar/LunarCompatProvider.java @@ -0,0 +1,99 @@ +package net.hollowcube.compat.lunar; + +import com.google.auto.service.AutoService; +import net.hollowcube.compat.api.CompatProvider; +import net.hollowcube.compat.api.ModChannelRegisterEvent; +import net.hollowcube.compat.api.discord.DiscordRichPresenceProvider; +import net.hollowcube.compat.api.packet.PacketRegistry; +import net.hollowcube.compat.lunar.events.LunarPlayerInitEvent; +import net.hollowcube.compat.lunar.packets.ClientboundLunarPacket; +import net.hollowcube.compat.lunar.packets.ServerboundLunarPacket; +import net.hollowcube.compat.lunar.payload.InstalledModsResponsePayload; +import net.hollowcube.compat.lunar.payload.LunarPayload; +import net.hollowcube.compat.lunar.payload.PaginatedPayloadHandler; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; +import net.minestom.server.event.GlobalEventHandler; +import net.minestom.server.tag.Tag; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; + + +@AutoService({CompatProvider.class, DiscordRichPresenceProvider.class}) +public class LunarCompatProvider implements CompatProvider, DiscordRichPresenceProvider { + + public static final Tag LUNAR_SUPPORT_ENABLED = Tag.Transient("mapmaker:lunar/enabled"); + private static final PaginatedPayloadHandler PAGINATED_PAYLOAD_HANDLER = new PaginatedPayloadHandler(); + + @Override + public void registerListeners(GlobalEventHandler events) { + events.addListener(ModChannelRegisterEvent.class, this::handleLunarPacket); + } + + @Override + public void registerPackets(PacketRegistry registry) { + registry.register(ClientboundLunarPacket.TYPE); + registry.register(ServerboundLunarPacket.APOLLO_JSON_TYPE, (player, packet) -> { + var payload = packet.payload(); + + if (payload instanceof LunarPayload.Paginated paginated) { + payload = PAGINATED_PAYLOAD_HANDLER.handle(paginated); + } + + if (payload != null) { + handleLunarPayload(player, payload); + } + }); + } + + private void handleLunarPacket(ModChannelRegisterEvent event) { + if (!event.getChannels().contains("lunar:apollo")) return; + if (event.getPlayer().hasTag(LUNAR_SUPPORT_ENABLED)) return; + + event.getPlayer().setTag(LUNAR_SUPPORT_ENABLED, true); + LunarPackets.getModSettingsPacket().send(event.getPlayer()); + LunarPackets.getEnableRichPresencePacket().send(event.getPlayer()); + LunarPackets.getRequestModsPacket().send(event.getPlayer()); + } + + private void handleLunarPayload(Player player, LunarPayload payload) { + if (payload instanceof InstalledModsResponsePayload response) { + EventDispatcher.call(new LunarPlayerInitEvent(player, response)); + } + } + + @Override + public void setRichPresence( + @NotNull Player player, + @NotNull String activity, @NotNull String name, + @Nullable String details + ) { + // This is a lunar bug, it seems to escape the / character and discord doesn't undo it + details = details != null ? details.replace("/", "∕") : ""; + + new ClientboundLunarPacket( + Map.of( + "@type", ClientboundLunarPacket.TYPE_PREFIX + "richpresence.v1.OverrideServerRichPresenceMessage", + "player_state", activity, + "game_name", name, + "game_variant_name", details + ) + ).send(player); + } + + @Override + public void clearRichPresence(@NotNull Player player) { + new ClientboundLunarPacket( + Map.of( + "@type", ClientboundLunarPacket.TYPE_PREFIX + "richpresence.v1.ResetServerRichPresenceMessage" + ) + ).send(player); + } + + @Override + public boolean isRichPresenceSupportedFor(@NotNull Player player) { + return player.hasTag(LUNAR_SUPPORT_ENABLED); + } +} diff --git a/modules/compat/src/main/java/net/hollowcube/compat/lunar/LunarPackets.java b/modules/compat/src/main/java/net/hollowcube/compat/lunar/LunarPackets.java new file mode 100644 index 000000000..ec12b222a --- /dev/null +++ b/modules/compat/src/main/java/net/hollowcube/compat/lunar/LunarPackets.java @@ -0,0 +1,47 @@ +package net.hollowcube.compat.lunar; + +import net.hollowcube.compat.lunar.packets.ClientboundLunarPacket; + +import java.util.Map; +import java.util.UUID; + +public class LunarPackets { + + private static final ClientboundLunarPacket MOD_SETTINGS = new ClientboundLunarPacket(Map.of( + "@type", ClientboundLunarPacket.TYPE_PREFIX + "configurable.v1.ConfigurableSettings", + "apollo_module", "mod_setting", + "enable", true, + "properties", Map.of( + "replaymod.enabled", true, + "minimap.enabled", false, + "weather-changer.enabled", false, + "day-counter.enabled", false, + "bossbar.enabled", false, + "saturation.enabled", false, + "direction-hud.enabled", false, + "armorstatus.enabled", false, + "titles.enabled", false + ) + )); + + private static final ClientboundLunarPacket ENABLE_RICH_PRESENCE = new ClientboundLunarPacket(Map.of( + "@type", ClientboundLunarPacket.TYPE_PREFIX + "configurable.v1.ConfigurableSettings", + "apollo_module", "rich_presence", + "enable", true + )); + + public static ClientboundLunarPacket getModSettingsPacket() { + return MOD_SETTINGS; + } + + public static ClientboundLunarPacket getEnableRichPresencePacket() { + return ENABLE_RICH_PRESENCE; + } + + public static ClientboundLunarPacket getRequestModsPacket() { + return new ClientboundLunarPacket(Map.of( + "@type", ClientboundLunarPacket.TYPE_PREFIX + "modsetting.v1.InstalledModsRequest", + "request_id", UUID.randomUUID().toString() + )); + } +} diff --git a/modules/compat/src/main/java/net/hollowcube/compat/lunar/events/LunarPlayerEvent.java b/modules/compat/src/main/java/net/hollowcube/compat/lunar/events/LunarPlayerEvent.java new file mode 100644 index 000000000..952e2b575 --- /dev/null +++ b/modules/compat/src/main/java/net/hollowcube/compat/lunar/events/LunarPlayerEvent.java @@ -0,0 +1,14 @@ +package net.hollowcube.compat.lunar.events; + +import net.minestom.server.entity.Player; +import net.minestom.server.event.trait.PlayerEvent; + +public interface LunarPlayerEvent extends PlayerEvent { + + Player player(); + + @Override + default Player getPlayer() { + return player(); + } +} diff --git a/modules/compat/src/main/java/net/hollowcube/compat/lunar/events/LunarPlayerInitEvent.java b/modules/compat/src/main/java/net/hollowcube/compat/lunar/events/LunarPlayerInitEvent.java new file mode 100644 index 000000000..eadb41333 --- /dev/null +++ b/modules/compat/src/main/java/net/hollowcube/compat/lunar/events/LunarPlayerInitEvent.java @@ -0,0 +1,11 @@ +package net.hollowcube.compat.lunar.events; + +import net.hollowcube.compat.lunar.payload.InstalledModsResponsePayload; +import net.minestom.server.entity.Player; + +public record LunarPlayerInitEvent( + Player player, + InstalledModsResponsePayload payload +) implements LunarPlayerEvent { + +} diff --git a/modules/compat/src/main/java/net/hollowcube/compat/lunar/events/package-info.java b/modules/compat/src/main/java/net/hollowcube/compat/lunar/events/package-info.java new file mode 100644 index 000000000..6e17f17e7 --- /dev/null +++ b/modules/compat/src/main/java/net/hollowcube/compat/lunar/events/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.hollowcube.compat.lunar.events; + +import org.jetbrains.annotations.NotNullByDefault; \ No newline at end of file diff --git a/modules/compat/src/main/java/net/hollowcube/compat/lunar/packets/ClientboundLunarPacket.java b/modules/compat/src/main/java/net/hollowcube/compat/lunar/packets/ClientboundLunarPacket.java new file mode 100644 index 000000000..62402281d --- /dev/null +++ b/modules/compat/src/main/java/net/hollowcube/compat/lunar/packets/ClientboundLunarPacket.java @@ -0,0 +1,29 @@ +package net.hollowcube.compat.lunar.packets; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import net.hollowcube.compat.api.packet.ClientboundModPacket; +import net.minestom.server.network.NetworkBuffer; +import org.jetbrains.annotations.NotNull; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +public record ClientboundLunarPacket( + @NotNull Map data +) implements ClientboundModPacket { + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + public static final String TYPE_PREFIX = "type.googleapis.com/lunarclient.apollo."; + + + public static final Type TYPE = Type.of( + "apollo", + "json", + (buffer, packet) -> buffer.write(NetworkBuffer.RAW_BYTES, GSON.toJson(packet.data).getBytes(StandardCharsets.UTF_8)) + ); + + @Override + public Type getType() { + return TYPE; + } +} diff --git a/modules/compat/src/main/java/net/hollowcube/compat/lunar/packets/ServerboundLunarPacket.java b/modules/compat/src/main/java/net/hollowcube/compat/lunar/packets/ServerboundLunarPacket.java new file mode 100644 index 000000000..69eeb70b5 --- /dev/null +++ b/modules/compat/src/main/java/net/hollowcube/compat/lunar/packets/ServerboundLunarPacket.java @@ -0,0 +1,33 @@ +package net.hollowcube.compat.lunar.packets; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.hollowcube.compat.api.packet.ServerboundModPacket; +import net.hollowcube.compat.lunar.payload.LunarPayload; +import net.hollowcube.compat.lunar.payload.LunarPayloadType; +import net.minestom.server.codec.Transcoder; +import net.minestom.server.network.NetworkBuffer; +import net.minestom.server.utils.ThrowingFunction; + +public record ServerboundLunarPacket(LunarPayload payload) implements ServerboundModPacket { + + private static final ThrowingFunction READER = buffer -> { + var string = new String(buffer.read(NetworkBuffer.RAW_BYTES)); + var json = JsonParser.parseString(string); + if (json instanceof JsonObject object) { + var type = object.get("@type").getAsString(); + var payloadType = LunarPayloadType.REGISTRY.get(type); + if (payloadType != null) { + return new ServerboundLunarPacket(payloadType.codec().decode(Transcoder.JSON, object).orElseThrow()); + } + } + return new ServerboundLunarPacket(new LunarPayload.Unhandled(json)); + }; + + public static final Type APOLLO_JSON_TYPE = Type.of("apollo", "json", READER); + + @Override + public Type getType() { + return null; + } +} diff --git a/modules/compat/src/main/java/net/hollowcube/compat/lunar/payload/InstalledModsResponsePagePayload.java b/modules/compat/src/main/java/net/hollowcube/compat/lunar/payload/InstalledModsResponsePagePayload.java new file mode 100644 index 000000000..90368642a --- /dev/null +++ b/modules/compat/src/main/java/net/hollowcube/compat/lunar/payload/InstalledModsResponsePagePayload.java @@ -0,0 +1,29 @@ +package net.hollowcube.compat.lunar.payload; + +import net.minestom.server.codec.Codec; +import net.minestom.server.codec.StructCodec; + +import java.util.List; + +public record InstalledModsResponsePagePayload( + String id, + int page, + int totalPages, + List groups +) implements LunarPayload.Paginated { + + public static final String ID = "lunarclient.apollo.modsetting.v1.InstalledModsResponse"; + public static final StructCodec CODEC = StructCodec.struct( + "request_id", Codec.STRING, InstalledModsResponsePagePayload::id, + "page", Codec.INT, InstalledModsResponsePagePayload::page, + "total_pages", Codec.INT, InstalledModsResponsePagePayload::totalPages, + "mod_groups", InstalledModsResponsePayload.ModGroup.CODEC.list(), InstalledModsResponsePagePayload::groups, + InstalledModsResponsePagePayload::new + ); + public static final LunarPayloadType TYPE = new LunarPayloadType<>(ID, CODEC); + + @Override + public LunarPayload group(List pages) { + return InstalledModsResponsePayload.fromPages(pages); + } +} diff --git a/modules/compat/src/main/java/net/hollowcube/compat/lunar/payload/InstalledModsResponsePayload.java b/modules/compat/src/main/java/net/hollowcube/compat/lunar/payload/InstalledModsResponsePayload.java new file mode 100644 index 000000000..8baf806ae --- /dev/null +++ b/modules/compat/src/main/java/net/hollowcube/compat/lunar/payload/InstalledModsResponsePayload.java @@ -0,0 +1,46 @@ +package net.hollowcube.compat.lunar.payload; + +import net.minestom.server.codec.Codec; +import net.minestom.server.codec.StructCodec; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public record InstalledModsResponsePayload(List groups) implements LunarPayload { + + public static InstalledModsResponsePayload fromPages(List pages) { + Map> groups = new HashMap<>(); + for (var page : pages) { + for (var group : page.groups()) { + groups.computeIfAbsent(group.type(), _ -> new ArrayList<>()).addAll(group.mods()); + } + } + + return new InstalledModsResponsePayload( + groups.entrySet() + .stream() + .map(e -> new ModGroup(e.getKey(), e.getValue())) + .toList() + ); + } + + public record ModGroup(String type, List mods) { + + public static final StructCodec CODEC = StructCodec.struct( + "type", Codec.STRING, ModGroup::type, + "mods", Mod.CODEC.list(), ModGroup::mods, + ModGroup::new + ); + } + + public record Mod(String id, String version) { + + public static final StructCodec CODEC = StructCodec.struct( + "id", Codec.STRING, Mod::id, + "version", Codec.STRING, Mod::version, + Mod::new + ); + } +} diff --git a/modules/compat/src/main/java/net/hollowcube/compat/lunar/payload/LunarPayload.java b/modules/compat/src/main/java/net/hollowcube/compat/lunar/payload/LunarPayload.java new file mode 100644 index 000000000..cab543aea --- /dev/null +++ b/modules/compat/src/main/java/net/hollowcube/compat/lunar/payload/LunarPayload.java @@ -0,0 +1,22 @@ +package net.hollowcube.compat.lunar.payload; + +import com.google.gson.JsonElement; + +import java.util.List; + +public interface LunarPayload { + + interface Paginated> extends LunarPayload { + String id(); + int page(); + int totalPages(); + + LunarPayload group(List pages); + + default boolean isLastPage() { + return this.page() >= this.totalPages() - 1; + } + } + + record Unhandled(JsonElement json) implements LunarPayload {} +} diff --git a/modules/compat/src/main/java/net/hollowcube/compat/lunar/payload/LunarPayloadType.java b/modules/compat/src/main/java/net/hollowcube/compat/lunar/payload/LunarPayloadType.java new file mode 100644 index 000000000..86dccb809 --- /dev/null +++ b/modules/compat/src/main/java/net/hollowcube/compat/lunar/payload/LunarPayloadType.java @@ -0,0 +1,20 @@ +package net.hollowcube.compat.lunar.payload; + +import net.minestom.server.codec.StructCodec; +import org.jetbrains.annotations.NotNullByDefault; + +import java.util.Map; + +@NotNullByDefault +public record LunarPayloadType(String name, StructCodec codec) { + + private static final String QUALIFIER = "type.googleapis.com/"; + + public static final Map> REGISTRY = Map.of( + InstalledModsResponsePagePayload.TYPE.fullyQualifiedName(), InstalledModsResponsePagePayload.TYPE + ); + + public String fullyQualifiedName() { + return QUALIFIER + this.name; + } +} diff --git a/modules/compat/src/main/java/net/hollowcube/compat/lunar/payload/PaginatedPayloadHandler.java b/modules/compat/src/main/java/net/hollowcube/compat/lunar/payload/PaginatedPayloadHandler.java new file mode 100644 index 000000000..f75909d18 --- /dev/null +++ b/modules/compat/src/main/java/net/hollowcube/compat/lunar/payload/PaginatedPayloadHandler.java @@ -0,0 +1,29 @@ +package net.hollowcube.compat.lunar.payload; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class PaginatedPayloadHandler { + + private final Cache>> received = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(1, TimeUnit.MINUTES) + .build(); + + @SuppressWarnings("unchecked") + public > @Nullable LunarPayload handle(LunarPayload.Paginated payload) { + var payloads = this.received.get(payload.id(), _ -> new ArrayList<>()); + payloads.add(payload); + if (payload.isLastPage()) { + this.received.invalidate(payload.id()); + + return payload.group((List) payloads); + } + return null; + } +} diff --git a/modules/map-editor/src/main/java/net/hollowcube/mapmaker/editor/EditorState.java b/modules/map-editor/src/main/java/net/hollowcube/mapmaker/editor/EditorState.java index 77d81da7d..a8494517d 100644 --- a/modules/map-editor/src/main/java/net/hollowcube/mapmaker/editor/EditorState.java +++ b/modules/map-editor/src/main/java/net/hollowcube/mapmaker/editor/EditorState.java @@ -1,5 +1,6 @@ package net.hollowcube.mapmaker.editor; +import net.hollowcube.compat.api.discord.DiscordRichPresenceManager; import net.hollowcube.compat.axiom.AxiomPlayer; import net.hollowcube.compat.noxesium.components.NoxesiumGameComponents; import net.hollowcube.compat.noxesium.handshake.NoxesiumPlayer; @@ -108,4 +109,9 @@ public void resetPlayer(EditorMapWorld world, Player player, @Nullable EditorSta } } + @Override + default void configurePlayer(EditorMapWorld world, Player player, @Nullable EditorState lastState) { + PlayerState.super.configurePlayer(world, player, lastState); + DiscordRichPresenceManager.queueRichPresenceUpdate(player, "Building", "a map", ""); + } } diff --git a/modules/map-runtime/src/main/java/net/hollowcube/mapmaker/runtime/parkour/ParkourState.java b/modules/map-runtime/src/main/java/net/hollowcube/mapmaker/runtime/parkour/ParkourState.java index 5eee5a5da..2a26b7dce 100644 --- a/modules/map-runtime/src/main/java/net/hollowcube/mapmaker/runtime/parkour/ParkourState.java +++ b/modules/map-runtime/src/main/java/net/hollowcube/mapmaker/runtime/parkour/ParkourState.java @@ -2,6 +2,7 @@ import net.hollowcube.common.util.FutureUtil; import net.hollowcube.common.util.ProtocolVersions; +import net.hollowcube.compat.api.discord.DiscordRichPresenceManager; import net.hollowcube.compat.noxesium.components.NoxesiumGameComponents; import net.hollowcube.compat.noxesium.handshake.NoxesiumPlayer; import net.hollowcube.mapmaker.ExceptionReporter; @@ -222,6 +223,8 @@ public void configurePlayer(ParkourMapWorld world, Player player, @Nullable Park // the touching state immediately because we should already be inside. ((MapPlayer) player).updateTouchingState(world, false); } + + queueRichPresenceUpdate(world, player, "Playing"); } @Override @@ -254,6 +257,8 @@ public void configurePlayer(ParkourMapWorld world, Player player, @Nullable Park ((MapPlayer) player).resetTouchingState(); ((MapPlayer) player).updateTouchingState(world, true); + + queueRichPresenceUpdate(world, player, "Testing"); } } @@ -297,6 +302,8 @@ public void configurePlayer(ParkourMapWorld world, Player player, @Nullable Park gameState.set(SpectateHelper.GAME_STATE_SAVED, true); } + + queueRichPresenceUpdate(world, player, "Spectating"); } @Override @@ -338,6 +345,8 @@ public void configurePlayer(ParkourMapWorld world, Player player, @Nullable Park // This is delegated back to the world kinda stupidly. We need to be able // to override the behavior for testing worlds world.performFinishEffects(player, saveState); + + queueRichPresenceUpdate(world, player, "Playing"); } @Override @@ -380,4 +389,18 @@ private static void writeSaveState(ParkourMapWorld world, Player player, SaveSta } } + private static void queueRichPresenceUpdate(ParkourMapWorld world, Player player, String activity) { + var map = world.map(); + if (map.isPublished()) { + DiscordRichPresenceManager.queueRichPresenceUpdate( + player, + activity, + map.name(), + "/play %s".formatted(map.publishedIdString()) + ); + } else { + DiscordRichPresenceManager.queueRichPresenceUpdate(player, activity, "a map", ""); + } + } + }