From 1df4776b68b0c72a9f3347dd3ed38c987f221cc1 Mon Sep 17 00:00:00 2001 From: NotAlexNoyle Date: Wed, 5 Aug 2026 12:00:11 -0400 Subject: [PATCH 01/11] Multi world support --- CHANGELOG.md | 12 ++ README.md | 111 ++++++++++++++- src/main/kotlin/nl/skbotnl/chatog/ChatOG.kt | 9 ++ src/main/kotlin/nl/skbotnl/chatog/Events.kt | 130 ++++++++++++++---- .../kotlin/nl/skbotnl/chatog/api/ChatOGAPI.kt | 34 +++++ .../skbotnl/chatog/api/WorldChatFormatter.kt | 13 ++ .../skbotnl/chatog/chatsystem/ChatSystem.kt | 29 ++-- .../chatog/chatsystem/GeneralChatSystem.kt | 5 +- .../chatog/chatsystem/WorldChatSystem.kt | 106 ++++++++++++++ .../chatog/config/discord/DiscordConfig.kt | 7 + .../skbotnl/chatog/discord/DiscordBridge.kt | 78 +++++++++-- src/main/resources/config.yml | 19 +++ 12 files changed, 501 insertions(+), 52 deletions(-) create mode 100644 src/main/kotlin/nl/skbotnl/chatog/api/ChatOGAPI.kt create mode 100644 src/main/kotlin/nl/skbotnl/chatog/api/WorldChatFormatter.kt create mode 100644 src/main/kotlin/nl/skbotnl/chatog/chatsystem/WorldChatSystem.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3978f20..6d28d9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +NotAlexNoyle 08/05/26: + +- Add multi world game chat. Worlds named `-hub` or `-` get their + own isolated chat and link to the Discord channel configured under `discord.games` for that prefix. +- Tell a game's worlds apart in Discord with the configurable `gameLobbyLabel` and `gameWorldLabel`. +- Route joins, quits, kicks, deaths and advancements to the world's channel. The main world and its + nether and end keep using the general channel; any other world is not reported. +- Post a joined and left pair to a game channel when a player is teleported in or out of its worlds, + so its player count stays balanced. +- Add `ChatOGAPI`, letting a plugin register a `WorldChatFormatter` for the worlds it owns. +- Stop relaying a message to Discord when the blocklist has already suppressed it in game. + NotAlexNoyle 05/06/26: - Port all uses of PlaceholderAPI to MiniPlaceholders / Utilities-OG APIs. diff --git a/README.md b/README.md index 92f09b2..6d74559 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,119 @@ Chat plugin for [TrueOG](https://github.com/true-og/true-og) - Translates chat messages using any OpenAI(-compatible) API. Click any chat or Discord message to translate it on-demand, and run command `/translatesettings ` to pick your preferred language (preferences persisted in KeyDB). - Discord bridge with custom emoji (Minecraft → Discord), animated NQN emoji support, clickable attachment links, and Unicode emoji-to-shortcode conversion (Emoji 17.0). - Multi-channel chat: general (`/g` / `/gc`), staff (`/s` / `/sc`), premium (`/p` / `/pc`) and developer (`/d` / `/dc`), each with its own Discord webhook. -- Private messaging (`/msg`, `/whisper`, `/pm`) with `/r` and `/reply` shortcuts. +- Multi world game chat: every world of a game gets its own chat, and all of a game's worlds share one Discord channel. +- Private messaging (`/msg`, `/whisper`, `/pm`) with `/r` and `/reply` shortcuts, working across every world. - Forwards joins, quits, kicks, advancements, deaths and broadcasts to Discord, with full Vanish-OG awareness so vanished players never leak into Discord. - LuckPerms-driven Discord role color mapping so that player rank based formatting permissions carry over to Discord. -- Pings in chat alert the mentioned player. Words both ways from Minecraft <-> Discord. +- Pings in chat alert the mentioned player. Works both ways from Minecraft <-> Discord. - Censor `@everyone`, `@here` and role mentions on the Discord side. -- API for other plugins to send messages through the Discord bridge. +- API for other plugins to send messages through the Discord bridge and to style multi world game chat. - Run command `/chatconfigreload` to fully reload the config and the Discord bridge at runtime. +## Multi world games + +A game is a set of worlds sharing one Discord channel. Worlds are matched by name: `HB1-hub` is a +lobby, `HB1-Ancient_Plateau` is a live game world, and both belong to the `HB` channel. + +```yaml +discord: + games: + HB: + enabled: true + channelId: "..." + webhook: "https://discord.com/api/webhooks/..." + + # %id% is the lobby id, for example HB1. + gameLobbyLabel: "[%id% - Lobby]" + gameWorldLabel: "[%id% - In Game]" +``` + +- Chat in a game world reaches only that world, plus the game's Discord channel. +- Joins, quits, kicks, deaths and advancements follow the same routing. The main world and its nether + and end keep using the general channel; any other world is not reported. +- Staff, premium and developer chat, private messages and `/broadcast` stay global in every world. +- `games` ships empty, so nothing changes until it is filled in. + +## API + +Add Chat-OG as a submodule: + +```bash +git submodule add https://github.com/true-og/Chat-OG libs/Chat-OG +``` + +`build.gradle.kts`: + +```kotlin +extra["kotlinAttribute"] = Attribute.of("kotlin-tag", Boolean::class.javaObjectType) +val kotlinAttribute: Attribute by rootProject.extra + +dependencies { + compileOnlyApi(project(":libs:Chat-OG")) { attributes { attribute(kotlinAttribute, true) } } +} +``` + +`plugin.yml`: + +```yaml +softdepend: + - Chat-OG +``` + +### Styling a game's chat + +Chat-OG decides who receives a message; your plugin decides how it looks. Register one formatter per +game key, the same key used under `discord.games`. + +Kotlin: + +```kotlin +ChatOGAPI.setFormatter("HB") { sender, message, worldName, lobbyId -> + Component.join( + JoinConfiguration.noSeparators(), + UtilitiesOG.trueogColorize("&9${sender.name}&8 » &r"), + message, + ) +} +``` + +Java: + +```java +ChatOGAPI.setFormatter("HB", (sender, message, worldName, lobbyId) -> + Component.join(JoinConfiguration.noSeparators(), + UtilitiesOG.trueogColorize("&9" + sender.getName() + "&8 » &r"), + message)); +``` + +`message` is already blocklist checked, colorized and permission gated, so build around it instead of +turning it back into a string. `trueogColorize` accepts both modern and legacy color codes. + +Return `null` to use Chat-OG's default format, or `Component.empty()` to drop the message. A +formatter that throws is logged and falls back to the default, so it cannot break chat. + +### `ChatOGAPI` + +| Method | Purpose | +| --- | --- | +| `isAvailable()` | Whether Chat-OG is enabled and ready. | +| `setFormatter(key, formatter)` | Sets a game's chat formatter. Pass `null` to clear it. | +| `getLobbyId(worldName)` | The lobby id for a world, e.g. `HB1`, or `null` if it is not a game world. | +| `broadcast(worldName, message)` | Sends a message to everyone in a world, in game only. | + +All of them are safe to call before Chat-OG loads. + +### Sending to Discord + +`ChatAPI` posts to the built-in channels and is registered with Bukkit's `ServicesManager`: + +```java +ChatAPI api = Bukkit.getServicesManager().getRegistration(ChatAPI.class).getProvider(); +api.sendMessage("Hello", "SomeName", uuid); +``` + +It also offers `sendStaffMessage`, `sendPremiumMessage`, `sendMessageWithBot` and `sendEmbed`. + ## Building ```./gradlew clean build eclipse --warning-mode all``` diff --git a/src/main/kotlin/nl/skbotnl/chatog/ChatOG.kt b/src/main/kotlin/nl/skbotnl/chatog/ChatOG.kt index bc5e44d..a88fc04 100755 --- a/src/main/kotlin/nl/skbotnl/chatog/ChatOG.kt +++ b/src/main/kotlin/nl/skbotnl/chatog/ChatOG.kt @@ -52,6 +52,11 @@ internal class ChatOG : JavaPlugin() { val discordBridgeLock = ReentrantReadWriteLock() var lastMessagedMap: MutableMap = HashMap() + // Only true once onEnable has fully succeeded, so the API never runs against a half started plugin. + @Volatile private var ready = false + + fun isReady() = ready + fun isConfigInitialized() = ::config.isInitialized fun isLanguageDatabaseInitialized() = ::languageDatabase.isInitialized @@ -130,9 +135,13 @@ internal class ChatOG : JavaPlugin() { val chatAPI = ChatAPI() this.server.servicesManager.register(ChatAPI::class.java, chatAPI, this, ServicePriority.Normal) + + ready = true } override fun onDisable() { + ready = false + if (isConfigInitialized()) { if (discordBridge != null) { discordBridgeLock.read { diff --git a/src/main/kotlin/nl/skbotnl/chatog/Events.kt b/src/main/kotlin/nl/skbotnl/chatog/Events.kt index 8608a29..73b58b2 100755 --- a/src/main/kotlin/nl/skbotnl/chatog/Events.kt +++ b/src/main/kotlin/nl/skbotnl/chatog/Events.kt @@ -17,6 +17,8 @@ import nl.skbotnl.chatog.ChatOG.Companion.config import nl.skbotnl.chatog.ChatOG.Companion.discordBridge import nl.skbotnl.chatog.ChatOG.Companion.discordBridgeLock import nl.skbotnl.chatog.ChatOG.Companion.scope +import nl.skbotnl.chatog.chatsystem.GeneralChatSystem +import nl.skbotnl.chatog.chatsystem.WorldChatSystem import nl.skbotnl.chatog.util.ChatUtil import nl.skbotnl.chatog.util.ChatUtil.legacyToMm import nl.skbotnl.chatog.util.PlayerExtensions.chatSystem @@ -25,6 +27,7 @@ import org.bukkit.event.EventHandler import org.bukkit.event.Listener import org.bukkit.event.entity.PlayerDeathEvent import org.bukkit.event.player.PlayerAdvancementDoneEvent +import org.bukkit.event.player.PlayerChangedWorldEvent import org.bukkit.event.player.PlayerJoinEvent import org.bukkit.event.player.PlayerKickEvent import org.bukkit.event.player.PlayerQuitEvent @@ -32,6 +35,21 @@ import org.bukkit.event.server.BroadcastMessageEvent import xyz.jpenilla.announcerplus.listener.JoinQuitListener internal class Events : Listener { + // The main world and its nether and end, taken from level-name rather than assumed to be "world". + private fun isMainWorld(worldName: String): Boolean { + val main = Bukkit.getWorlds().firstOrNull()?.name ?: return false + return worldName.equals(main, true) || + worldName.equals("${main}_nether", true) || + worldName.equals("${main}_the_end", true) + } + + // A multi world game world reports to its own channel, the main world to general, anything else nowhere. + private fun reports(worldName: String, key: String?) = key != null || isMainWorld(worldName) + + // A game channel counts only its own players, since the server total would mislead there. + private fun onlineCount(key: String?) = + if (key == null) Bukkit.getOnlinePlayers().count() else WorldChatSystem.playersForKey(key).size + @EventHandler fun onJoin(event: PlayerJoinEvent) { if (!config.discord.enabled) { @@ -41,18 +59,17 @@ internal class Events : Listener { return } + val worldName = event.player.world.name + val key = WorldChatSystem.keyForWorld(worldName) + if (!reports(worldName, key)) { + return + } + val playerPartString = ChatUtil.getPlayerPartString(event.player) + val message = "$playerPartString has joined the game. ${onlineCount(key)} player(s) online." scope.launch { - discordBridgeLock.read { - discordBridge?.sendEmbed( - "$playerPartString has joined the game. ${ - Bukkit.getOnlinePlayers().count() - } player(s) online.", - event.player.uniqueId, - 0x00FF00, - ) - } + discordBridgeLock.read { discordBridge?.sendEmbed(message, event.player.uniqueId, 0x00FF00, key) } } } @@ -65,17 +82,47 @@ internal class Events : Listener { return } + val worldName = event.player.world.name + val key = WorldChatSystem.keyForWorld(worldName) + if (!reports(worldName, key)) { + return + } + val playerPartString = ChatUtil.getPlayerPartString(event.player) + val message = "$playerPartString has left the game. ${onlineCount(key) - 1} player(s) online." + + scope.launch { + discordBridgeLock.read { discordBridge?.sendEmbed(message, event.player.uniqueId, 0xFF0000, key) } + } + } + + // Game players are teleported in rather than connecting, so without this a game channel only sees leaves. + @EventHandler + fun onWorldChange(event: PlayerChangedWorldEvent) { + if (!config.discord.enabled) { + return + } + if (VanishAPI.isVanished(event.player)) { + return + } + + // Only the game channels track a per world population; general counts the whole server, which + // a world change does not alter. + val fromKey = WorldChatSystem.keyForWorld(event.from.name) + val toKey = WorldChatSystem.keyForWorld(event.player.world.name) + if (fromKey == toKey) { + return + } + + val playerPartString = ChatUtil.getPlayerPartString(event.player) + val leftMessage = fromKey?.let { "$playerPartString has left the game. ${onlineCount(it)} player(s) online." } + val joinedMessage = toKey?.let { "$playerPartString has joined the game. ${onlineCount(it)} player(s) online." } scope.launch { discordBridgeLock.read { - discordBridge?.sendEmbed( - "$playerPartString has left the game. ${ - Bukkit.getOnlinePlayers().count() - 1 - } player(s) online.", - event.player.uniqueId, - 0xFF0000, - ) + if (leftMessage != null) discordBridge?.sendEmbed(leftMessage, event.player.uniqueId, 0xFF0000, fromKey) + if (joinedMessage != null) + discordBridge?.sendEmbed(joinedMessage, event.player.uniqueId, 0x00FF00, toKey) } } } @@ -89,20 +136,20 @@ internal class Events : Listener { return } + val worldName = event.player.world.name + val key = WorldChatSystem.keyForWorld(worldName) + if (!reports(worldName, key)) { + return + } + val playerPartString = ChatUtil.getPlayerPartString(event.player) val reason = PlainTextComponentSerializer.plainText().serialize(event.reason()) + val message = + "$playerPartString was kicked with reason: \"${reason}\". ${onlineCount(key) - 1} player(s) online." scope.launch { - discordBridgeLock.read { - discordBridge?.sendEmbed( - "$playerPartString was kicked with reason: \"${reason}\". ${ - Bukkit.getOnlinePlayers().count() - 1 - } player(s) online.", - event.player.uniqueId, - 0xFF0000, - ) - } + discordBridgeLock.read { discordBridge?.sendEmbed(message, event.player.uniqueId, 0xFF0000, key) } } } @@ -115,6 +162,12 @@ internal class Events : Listener { return } + val worldName = event.player.world.name + val key = WorldChatSystem.keyForWorld(worldName) + if (!reports(worldName, key)) { + return + } + val playerPartString = ChatUtil.getPlayerPartString(event.player) val advancementTitleKey = event.advancement.display?.title() ?: return @@ -132,7 +185,7 @@ internal class Events : Listener { scope.launch { discordBridgeLock.read { - discordBridge?.sendEmbed("$playerPartString $advancementMessage.", event.player.uniqueId, 0xFFFF00) + discordBridge?.sendEmbed("$playerPartString $advancementMessage.", event.player.uniqueId, 0xFFFF00, key) } } } @@ -161,9 +214,15 @@ internal class Events : Listener { if (event.isCancelled) return event.isCancelled = true + val worldName = event.player.world.name + scope.launch { val eventMessage = event.message() as TextComponent - event.player.chatSystem.sendMessage(eventMessage.content(), event.player) + val chatSystem = event.player.chatSystem + // A multi world game world replaces general chat only, so staff, premium and developer stay global. + val effective = + if (chatSystem === GeneralChatSystem) WorldChatSystem.forWorld(worldName) ?: chatSystem else chatSystem + effective.sendMessage(eventMessage.content(), event.player) } } @@ -176,11 +235,18 @@ internal class Events : Listener { return } + // The in game death message is still rewritten below even when the world reports nowhere. + val worldName = event.player.world.name + val key = WorldChatSystem.keyForWorld(worldName) + val report = reports(worldName, key) + val deathMessage = event.deathMessage() if (deathMessage is TextComponent) { - scope.launch { - discordBridgeLock.read { - discordBridge?.sendEmbed(deathMessage.content(), event.player.uniqueId, 0xFF0000) + if (report) { + scope.launch { + discordBridgeLock.read { + discordBridge?.sendEmbed(deathMessage.content(), event.player.uniqueId, 0xFF0000, key) + } } } return @@ -195,12 +261,16 @@ internal class Events : Listener { val newDeathMessage = builder.build() event.deathMessage(newDeathMessage) + if (!report) { + return + } scope.launch { discordBridgeLock.read { discordBridge?.sendEmbed( PlainTextComponentSerializer.plainText().serialize(newDeathMessage), event.player.uniqueId, 0xFF0000, + key, ) } } diff --git a/src/main/kotlin/nl/skbotnl/chatog/api/ChatOGAPI.kt b/src/main/kotlin/nl/skbotnl/chatog/api/ChatOGAPI.kt new file mode 100644 index 0000000..2655c55 --- /dev/null +++ b/src/main/kotlin/nl/skbotnl/chatog/api/ChatOGAPI.kt @@ -0,0 +1,34 @@ +package nl.skbotnl.chatog.api + +import net.kyori.adventure.text.Component +import nl.skbotnl.chatog.ChatOG +import nl.skbotnl.chatog.chatsystem.WorldChatSystem + +// Multi world game chat API. Worlds are bound to Discord channels in Chat-OG's config, so a plugin +// only ever supplies formatting. Callers must declare softdepend: [Chat-OG] in their plugin.yml. +object ChatOGAPI { + // False until Chat-OG has fully enabled, and again once it disables. + @JvmStatic fun isAvailable(): Boolean = ChatOG.isReady() + + // Sets the chat formatter for a game key, for example "HB". False when Chat-OG is not ready. + @JvmStatic + fun setFormatter(key: String, formatter: WorldChatFormatter?): Boolean { + if (!isAvailable()) return false + WorldChatSystem.setFormatter(key, formatter) + return true + } + + // Lobby id for a world, for example "HB1", or null if it is not part of a multi world game. + @JvmStatic + fun getLobbyId(worldName: String): String? { + if (!isAvailable()) return null + return WorldChatSystem.forWorld(worldName)?.id + } + + // Sends a message to everyone in a world and returns how many players received it. + @JvmStatic + fun broadcast(worldName: String, message: Component): Int { + if (!isAvailable()) return 0 + return WorldChatSystem.broadcast(worldName, message) + } +} diff --git a/src/main/kotlin/nl/skbotnl/chatog/api/WorldChatFormatter.kt b/src/main/kotlin/nl/skbotnl/chatog/api/WorldChatFormatter.kt new file mode 100644 index 0000000..f154517 --- /dev/null +++ b/src/main/kotlin/nl/skbotnl/chatog/api/WorldChatFormatter.kt @@ -0,0 +1,13 @@ +package nl.skbotnl.chatog.api + +import net.kyori.adventure.text.Component +import org.bukkit.entity.Player + +// Lets a plugin render its own chat line for the multi world game it owns. +fun interface WorldChatFormatter { + // The message is already blocklist checked, colorized and permission gated by Chat-OG. + // Never serialize it back to a string and re-parse it, or players without chat-og.color regain formatting. + // Compose instead, for example Component.join(noSeparators(), UtilitiesOG.trueogColorize(prefix), message). + // Return the finished line, null to use Chat-OG's default format, or Component.empty() to drop the message. + fun format(sender: Player, message: Component, worldName: String, lobbyId: String): Component? +} diff --git a/src/main/kotlin/nl/skbotnl/chatog/chatsystem/ChatSystem.kt b/src/main/kotlin/nl/skbotnl/chatog/chatsystem/ChatSystem.kt index 86ac932..3e215ea 100644 --- a/src/main/kotlin/nl/skbotnl/chatog/chatsystem/ChatSystem.kt +++ b/src/main/kotlin/nl/skbotnl/chatog/chatsystem/ChatSystem.kt @@ -39,24 +39,35 @@ internal abstract class ChatSystem { abstract fun sendDiscordMessage(text: String, playerPartString: String, uuid: UUID) + // Lets a subclass render its own chat line. Null keeps the default format, empty drops the message. + protected open fun formatLine(player: Player, message: Component): Component? = null + fun sendMessage(text: String, player: Player) { + val messageComponent = + ChatUtil.processText(text, player)?.color(PlayerUtils.getMessageColor(player.uniqueId)) ?: return + + // Runs before the relay so a dropped message never reaches Discord either. + val formattedLine = formatLine(player, messageComponent) + if (formattedLine == Component.empty()) return + var playerPartString = ChatUtil.getPlayerPartString(player) playerPartString = listOfNotNull(prefix, playerPartString).joinToString(" | ") val discordPlayerPartString = listOfNotNull(prefix, ChatUtil.getPlayerPartString(player, includeSuffix = true)).joinToString(" | ") + // Relayed only after the blocklist check so a suppressed message never reaches Discord. sendDiscordMessage(resolveChatItems(text), discordPlayerPartString, player.uniqueId) - val messageComponent = - ChatUtil.processText(text, player)?.color(PlayerUtils.getMessageColor(player.uniqueId)) ?: return - - val chatComponent = - UtilitiesOG.trueogColorize( - ChatUtil.legacyToMm("$playerPartString${PlayerUtils.getSuffix(player.uniqueId)} &7> ") - ) - - var textComponent = Component.join(JoinConfiguration.noSeparators(), chatComponent, messageComponent) + var textComponent = + formattedLine + ?: Component.join( + JoinConfiguration.noSeparators(), + UtilitiesOG.trueogColorize( + ChatUtil.legacyToMm("$playerPartString${PlayerUtils.getSuffix(player.uniqueId)} &7> ") + ), + messageComponent, + ) textComponent = textComponent.hoverEvent( HoverEvent.hoverEvent( diff --git a/src/main/kotlin/nl/skbotnl/chatog/chatsystem/GeneralChatSystem.kt b/src/main/kotlin/nl/skbotnl/chatog/chatsystem/GeneralChatSystem.kt index b33057f..55994d0 100644 --- a/src/main/kotlin/nl/skbotnl/chatog/chatsystem/GeneralChatSystem.kt +++ b/src/main/kotlin/nl/skbotnl/chatog/chatsystem/GeneralChatSystem.kt @@ -13,8 +13,11 @@ import org.bukkit.Bukkit internal object GeneralChatSystem : ChatSystem() { override val prefix = null + + // Multi world game worlds have their own chat, so global chat must not reach into them either. override val audience: Audience - get() = Audience.audience(Bukkit.getOnlinePlayers()) + get() = + Audience.audience(Bukkit.getOnlinePlayers().filter { WorldChatSystem.keyForWorld(it.world.name) == null }) override val name = "general" diff --git a/src/main/kotlin/nl/skbotnl/chatog/chatsystem/WorldChatSystem.kt b/src/main/kotlin/nl/skbotnl/chatog/chatsystem/WorldChatSystem.kt new file mode 100644 index 0000000..a303500 --- /dev/null +++ b/src/main/kotlin/nl/skbotnl/chatog/chatsystem/WorldChatSystem.kt @@ -0,0 +1,106 @@ +package nl.skbotnl.chatog.chatsystem + +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import kotlin.concurrent.read +import kotlinx.coroutines.launch +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.text.Component +import nl.skbotnl.chatog.ChatOG.Companion.config +import nl.skbotnl.chatog.ChatOG.Companion.discordBridge +import nl.skbotnl.chatog.ChatOG.Companion.discordBridgeLock +import nl.skbotnl.chatog.ChatOG.Companion.plugin +import nl.skbotnl.chatog.ChatOG.Companion.scope +import nl.skbotnl.chatog.api.WorldChatFormatter +import nl.skbotnl.chatog.util.ChatUtil +import org.bukkit.Bukkit +import org.bukkit.entity.Player + +// Chat for one world of a multi world game, isolated in game but sharing the game's Discord channel. +internal class WorldChatSystem(private val worldName: String, val key: String, val id: String, lobby: Boolean) : + ChatSystem() { + // No label in game, since everyone who can read this chat is already in the same world. + override val prefix = null + + // The label only tags the webhook username, which is what tells a game's worlds apart in Discord. + private val label = + (if (lobby) config.discord.gameLobbyLabel ?: DEFAULT_LOBBY_LABEL + else config.discord.gameWorldLabel ?: DEFAULT_WORLD_LABEL) + .replace("%id%", id) + + // Filtering the online players avoids touching World.getPlayers() from the async chat coroutine. + override val audience: Audience + get() = Audience.audience(Bukkit.getOnlinePlayers().filter { it.world.name == worldName }) + + override val name = "world:$worldName" + + override fun sendDiscordMessage(text: String, playerPartString: String, uuid: UUID) { + if (!config.discord.enabled || config.discord.games[key]?.enabled != true) return + val discordMessageString = ChatUtil.convertEmojis(text) + + scope.launch { + discordBridgeLock.read { + discordBridge?.sendGameMessage(key, discordMessageString, "$label $playerPartString", uuid) + } + } + } + + // A formatter belongs to another plugin, so a failure there must not take the chat line down with it. + override fun formatLine(player: Player, message: Component): Component? { + val formatter = formatters[key.uppercase()] ?: return null + return try { + formatter.format(player, message, worldName, id) + } catch (throwable: Throwable) { + plugin.logger.warning("The chat formatter for game \"$key\" failed: $throwable") + null + } + } + + companion object { + private const val DEFAULT_LOBBY_LABEL = "[%id% - Lobby]" + private const val DEFAULT_WORLD_LABEL = "[%id% - In Game]" + + // Keyed by uppercased game key so registration order and config casing cannot disagree. + private val formatters = ConcurrentHashMap() + + // Worlds are named -hub for a lobby and - for a game, so HB1-hub and + // HB1-Ancient_Plateau both belong to the configured discord.games key "HB". + fun forWorld(worldName: String): WorldChatSystem? { + var index = 0 + while (index < worldName.length && worldName[index].isLetter()) index++ + if (index == 0) return null + + val digitStart = index + while (index < worldName.length && worldName[index].isDigit()) index++ + if (index == digitStart) return null + + if (index >= worldName.length || worldName[index] != '-') return null + val rest = worldName.substring(index + 1) + if (rest.isEmpty()) return null + + val prefix = worldName.substring(0, digitStart) + // Resolved from the configured key alone, so turning a game's Discord channel off does not + // also drop its worlds back into global chat. + val key = config.discord.games.keys.firstOrNull { it.equals(prefix, ignoreCase = true) } ?: return null + + return WorldChatSystem(worldName, key, worldName.substring(0, index), rest.equals("hub", true)) + } + + fun keyForWorld(worldName: String): String? = forWorld(worldName)?.key + + fun playersForKey(key: String): List = + Bukkit.getOnlinePlayers().filter { keyForWorld(it.world.name) == key } + + // A null formatter clears the one registered for that game. + fun setFormatter(key: String, formatter: WorldChatFormatter?) { + if (formatter == null) formatters.remove(key.uppercase()) else formatters[key.uppercase()] = formatter + } + + // In game only. Chat is what belongs in Discord, not every game announcement. + fun broadcast(worldName: String, message: Component): Int { + val recipients = Bukkit.getOnlinePlayers().filter { it.world.name == worldName } + Audience.audience(recipients).sendMessage(message) + return recipients.size + } + } +} diff --git a/src/main/kotlin/nl/skbotnl/chatog/config/discord/DiscordConfig.kt b/src/main/kotlin/nl/skbotnl/chatog/config/discord/DiscordConfig.kt index 5959fd5..ab9c3bc 100644 --- a/src/main/kotlin/nl/skbotnl/chatog/config/discord/DiscordConfig.kt +++ b/src/main/kotlin/nl/skbotnl/chatog/config/discord/DiscordConfig.kt @@ -18,6 +18,13 @@ constructor( @param:JsonProperty("staff") val staff: DiscordRoleChatConfig, @param:JsonProperty("premium") val premium: DiscordRoleChatConfig, @param:JsonProperty("developer") val developer: DiscordRoleChatConfig, + // Multi world game channels keyed by lobby prefix, e.g. "HB" for the worlds HB1-hub and HB1-. + @param:JsonProperty("games") + @field:JsonSetter(nulls = Nulls.AS_EMPTY) + val games: Map, + // Null falls back to the defaults in WorldChatSystem so older configs keep loading. + @param:JsonProperty("gameLobbyLabel") val gameLobbyLabel: String?, + @param:JsonProperty("gameWorldLabel") val gameWorldLabel: String?, @param:JsonProperty("listCommandName") val listCommandName: String, @param:JsonProperty("listCommandText") val listCommandText: String, @param:JsonProperty("useColorCodeRoles") val useColorCodeRoles: Boolean, diff --git a/src/main/kotlin/nl/skbotnl/chatog/discord/DiscordBridge.kt b/src/main/kotlin/nl/skbotnl/chatog/discord/DiscordBridge.kt index 34e2848..bb40b29 100644 --- a/src/main/kotlin/nl/skbotnl/chatog/discord/DiscordBridge.kt +++ b/src/main/kotlin/nl/skbotnl/chatog/discord/DiscordBridge.kt @@ -30,6 +30,7 @@ import net.kyori.adventure.text.format.TextColor import net.trueog.utilitiesog.UtilitiesOG import nl.skbotnl.chatog.ChatOG.Companion.config import nl.skbotnl.chatog.ChatOG.Companion.plugin +import nl.skbotnl.chatog.chatsystem.WorldChatSystem import nl.skbotnl.chatog.translation.command.TranslateMessage import nl.skbotnl.chatog.util.ChatUtil import nl.skbotnl.chatog.util.EmojiConverter @@ -42,8 +43,13 @@ internal class DiscordBridge private constructor() { private var staffWebhook: WebhookClient? = null private var premiumWebhook: WebhookClient? = null private var developerWebhook: WebhookClient? = null + private val gameWebhooks = mutableMapOf() companion object { + // The discord.games key whose channel this is, or null when it is not a game channel. + private fun gameKeyOf(channelId: String): String? = + config.discord.games.entries.firstOrNull { it.value.enabled && it.value.channelId == channelId }?.key + fun create(): DiscordBridge? { val discordBridge = DiscordBridge() if (config.discord.general.webhook != null) { @@ -84,6 +90,37 @@ internal class DiscordBridge private constructor() { } else if (config.discord.developer.enabled) { plugin.logger.warning("You have enabled developer Discord but have not set up the developer webhook") } + for ((key, game) in config.discord.games) { + if (!game.enabled) continue + if (game.channelId == null) { + plugin.logger.warning( + "You have enabled game Discord \"$key\" but have not set up its channelId, so Discord to Minecraft will not work for it" + ) + } + if (game.webhook == null) { + plugin.logger.warning("You have enabled game Discord \"$key\" but have not set up its webhook") + continue + } + try { + discordBridge.gameWebhooks[key] = WebhookClient.withUrl(game.webhook.toString()) + } catch (_: IllegalArgumentException) { + plugin.logger.warning("Config option \"games.$key.webhook\" is invalid") + } + } + // Two channels sharing an id would silently steal each other's messages, so say so loudly. + val channelIds = + listOfNotNull( + config.discord.general.channelId, + config.discord.staff.channelId.takeIf { config.discord.staff.enabled }, + config.discord.premium.channelId.takeIf { config.discord.premium.enabled }, + config.discord.developer.channelId.takeIf { config.discord.developer.enabled }, + ) + config.discord.games.values.filter { it.enabled }.mapNotNull { it.channelId } + channelIds + .groupingBy { it } + .eachCount() + .filterValues { it > 1 } + .keys + .forEach { plugin.logger.severe("Discord channel id \"$it\" is configured for more than one channel") } if (config.discord.botToken == null) { plugin.logger.severe("You have enabled Discord but have not set up the bot token") @@ -146,7 +183,8 @@ internal class DiscordBridge private constructor() { (if (config.discord.premium.enabled) it.channel.id != config.discord.premium.channelId else true) && (if (config.discord.developer.enabled) it.channel.id != config.discord.developer.channelId - else true) + else true) && + gameKeyOf(it.channel.id) == null ) { return@listener } @@ -293,7 +331,8 @@ internal class DiscordBridge private constructor() { ) } - config.discord.general.channelId -> { + // General and the game channels are both unlabelled. + else -> { Component.join( JoinConfiguration.noSeparators(), discordComponent, @@ -302,10 +341,6 @@ internal class DiscordBridge private constructor() { contentComponent, ) } - - else -> { - throw RuntimeException("Invalid channel id") - } } messageComponent = @@ -347,14 +382,20 @@ internal class DiscordBridge private constructor() { } } + // Skips the game worlds, which have their own channel and their own chat. config.discord.general.channelId -> { for (p in Bukkit.getOnlinePlayers()) { + if (WorldChatSystem.keyForWorld(p.world.name) != null) continue p.sendMessage(messageComponent) } } + // A game channel reaches only the players inside that game's worlds. else -> { - throw RuntimeException("Invalid channel id") + val gameKey = gameKeyOf(it.channel.id) ?: return@listener + for (p in WorldChatSystem.playersForKey(gameKey)) { + p.sendMessage(messageComponent) + } } } @@ -473,7 +514,26 @@ internal class DiscordBridge private constructor() { developerWebhook!!.send(webhookMessage.build()) } - fun sendEmbed(message: String, uuid: UUID?, color: Int?) { + suspend fun sendGameMessage(key: String, message: String, name: String, uuid: UUID?) { + // create() already warned about this once at startup, so stay quiet rather than log per message. + val gameWebhook = gameWebhooks[key] ?: return + + val webhookMessage = + WebhookMessageBuilder().apply { + setUsername(UtilitiesOG.stripFormatting(name)) + setContent( + UtilitiesOG.stripFormatting(ChatUtil.stripGroupMentions((ChatUtil.convertMentions(message)))) + ) + if (uuid != null) setAvatarUrl("https://minotar.net/helm/$uuid.png") + } + + gameWebhook.send(webhookMessage.build()) + } + + // A null key sends to the general channel. + fun sendEmbed(message: String, uuid: UUID?, color: Int?, key: String? = null) { + val target = if (key == null) webhook else gameWebhooks[key] ?: return + var iconUrl: String? = null if (uuid != null) { iconUrl = "https://minotar.net/helm/$uuid.png" @@ -486,6 +546,6 @@ internal class DiscordBridge private constructor() { .setAuthor(WebhookEmbed.EmbedAuthor(UtilitiesOG.stripFormatting(message), iconUrl, null)) } - webhook.send(webhookMessage.build()) + target.send(webhookMessage.build()) } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 5bc3741..be6e7f0 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -58,6 +58,25 @@ discord: channelId: ^{DISCORD_DEVELOPER_ID} webhook: ^{DISCORD_DEVELOPER_WEBHOOK} + # Multi world game channels, keyed by the lobby prefix used by the game's own config. + # Worlds are named -hub for a lobby and - for a game, + # so HB1-hub and HB1-Ancient_Plateau both link to the HB channel. + # Chat, joins, quits, kicks, deaths and advancements in those worlds go there instead of general. + # Every entry needs all three keys, and an unresolved webhook stops the plugin from loading. + games: + HB: + enabled: true + channelId: ^{DISCORD_HB_ID} + webhook: ^{DISCORD_HB_WEBHOOK} + SP: + enabled: true + channelId: ^{DISCORD_SP_ID} + webhook: ^{DISCORD_SP_WEBHOOK} + + # Discord label for multi world game worlds. %id% is the lobby id, for example HB1. + gameLobbyLabel: "[%id% - Lobby]" + gameWorldLabel: "[%id% - In Game]" + # List players command listCommandName: list # Available placeholders: %onlineplayers%, %maxplayers% -- 2.47.3 From bd00db18ba4132033a924919f3c667b4dc64b988 Mon Sep 17 00:00:00 2001 From: NotAlexNoyle Date: Tue, 11 Aug 2026 14:10:18 -0400 Subject: [PATCH 02/11] Added the BB game key so BuildBattle worlds route to their own chat and Discord channel, and corrected the README's stale claim that games ships empty. --- README.md | 4 +++- src/main/resources/config.yml | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6d74559..cf1fe20 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,9 @@ discord: - Joins, quits, kicks, deaths and advancements follow the same routing. The main world and its nether and end keep using the general channel; any other world is not reported. - Staff, premium and developer chat, private messages and `/broadcast` stay global in every world. -- `games` ships empty, so nothing changes until it is filled in. +- `games` ships with `HB` (TheHerobrine-OG), `SP` (Splegg-OG) and `BB` (BuildBattle-OG). Remove a key to drop + its worlds back into global chat; a key present isolates those worlds even while `enabled` is false, which + only gates the Discord side. ## API diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index be6e7f0..4e2c4b3 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -72,6 +72,10 @@ discord: enabled: true channelId: ^{DISCORD_SP_ID} webhook: ^{DISCORD_SP_WEBHOOK} + BB: + enabled: true + channelId: ^{DISCORD_BB_ID} + webhook: ^{DISCORD_BB_WEBHOOK} # Discord label for multi world game worlds. %id% is the lobby id, for example HB1. gameLobbyLabel: "[%id% - Lobby]" -- 2.47.3 From 2dcfe29d9dbb04d747372490dce8561dfab3c5b7 Mon Sep 17 00:00:00 2001 From: NotAlexNoyle Date: Tue, 11 Aug 2026 14:33:22 -0400 Subject: [PATCH 03/11] Update Vanish-OG --- libs/Vanish-OG | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/Vanish-OG b/libs/Vanish-OG index 3dee6ed..64d652a 160000 --- a/libs/Vanish-OG +++ b/libs/Vanish-OG @@ -1 +1 @@ -Subproject commit 3dee6ed808949db0713f2c45a17cfe3b8eb4a7b2 +Subproject commit 64d652a6760ab5fc6356e1e65207027eb1bae36c -- 2.47.3 From 7d7ea47d3325855877a8454a3c5e2f131e4b7571 Mon Sep 17 00:00:00 2001 From: NotAlexNoyle Date: Wed, 12 Aug 2026 07:47:28 -0400 Subject: [PATCH 04/11] Include world in join and quit messages for multi world games, add world specific player count for multi world games --- src/main/kotlin/nl/skbotnl/chatog/Events.kt | 28 ++++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/nl/skbotnl/chatog/Events.kt b/src/main/kotlin/nl/skbotnl/chatog/Events.kt index 73b58b2..21db917 100755 --- a/src/main/kotlin/nl/skbotnl/chatog/Events.kt +++ b/src/main/kotlin/nl/skbotnl/chatog/Events.kt @@ -50,6 +50,16 @@ internal class Events : Listener { private fun onlineCount(key: String?) = if (key == null) Bukkit.getOnlinePlayers().count() else WorldChatSystem.playersForKey(key).size + // Count players in a specific world. + private fun onlineCountInWorld(worldName: String): Int = Bukkit.getWorld(worldName)?.players?.size ?: 0 + + // Gets the lobby/game label for a world, e.g. "HB1 Lobby" or "HB1 Game". + private fun getGameLabel(worldName: String): String? { + val worldChatSystem = WorldChatSystem.forWorld(worldName) ?: return null + val type = if (worldName.endsWith("-hub", ignoreCase = true)) "Lobby" else "Game" + return "${worldChatSystem.id} $type" + } + @EventHandler fun onJoin(event: PlayerJoinEvent) { if (!config.discord.enabled) { @@ -66,7 +76,8 @@ internal class Events : Listener { } val playerPartString = ChatUtil.getPlayerPartString(event.player) - val message = "$playerPartString has joined the game. ${onlineCount(key)} player(s) online." + val gameLabel = getGameLabel(worldName)?.let { " the $it" } ?: "" + val message = "$playerPartString has joined$gameLabel. ${onlineCountInWorld(worldName)} player(s) online." scope.launch { discordBridgeLock.read { discordBridge?.sendEmbed(message, event.player.uniqueId, 0x00FF00, key) } @@ -89,7 +100,8 @@ internal class Events : Listener { } val playerPartString = ChatUtil.getPlayerPartString(event.player) - val message = "$playerPartString has left the game. ${onlineCount(key) - 1} player(s) online." + val gameLabel = getGameLabel(worldName)?.let { " the $it" } ?: "" + val message = "$playerPartString has left$gameLabel. ${onlineCountInWorld(worldName) - 1} player(s) online." scope.launch { discordBridgeLock.read { discordBridge?.sendEmbed(message, event.player.uniqueId, 0xFF0000, key) } @@ -115,8 +127,16 @@ internal class Events : Listener { } val playerPartString = ChatUtil.getPlayerPartString(event.player) - val leftMessage = fromKey?.let { "$playerPartString has left the game. ${onlineCount(it)} player(s) online." } - val joinedMessage = toKey?.let { "$playerPartString has joined the game. ${onlineCount(it)} player(s) online." } + val fromGameLabel = getGameLabel(event.from.name)?.let { " the $it" } ?: "" + val toGameLabel = getGameLabel(event.player.world.name)?.let { " the $it" } ?: "" + val leftMessage = + fromKey?.let { + "$playerPartString has left$fromGameLabel. ${onlineCountInWorld(event.from.name)} player(s) online." + } + val joinedMessage = + toKey?.let { + "$playerPartString has joined$toGameLabel. ${onlineCountInWorld(event.player.world.name)} player(s) online." + } scope.launch { discordBridgeLock.read { -- 2.47.3 From 9b71a3d0a814ee171ae1970e25f2b9cfbc7107ea Mon Sep 17 00:00:00 2001 From: NotAlexNoyle Date: Wed, 12 Aug 2026 08:32:02 -0400 Subject: [PATCH 05/11] Server wide player count for main worlds in join and quit messages --- src/main/kotlin/nl/skbotnl/chatog/Events.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/nl/skbotnl/chatog/Events.kt b/src/main/kotlin/nl/skbotnl/chatog/Events.kt index 21db917..1e9e2bc 100755 --- a/src/main/kotlin/nl/skbotnl/chatog/Events.kt +++ b/src/main/kotlin/nl/skbotnl/chatog/Events.kt @@ -50,8 +50,19 @@ internal class Events : Listener { private fun onlineCount(key: String?) = if (key == null) Bukkit.getOnlinePlayers().count() else WorldChatSystem.playersForKey(key).size - // Count players in a specific world. - private fun onlineCountInWorld(worldName: String): Int = Bukkit.getWorld(worldName)?.players?.size ?: 0 + // Count players in a specific world. For the standard main world trio ("world", "world_nether", "world_the_end"), + // return the server-wide online player count so Discord shows merged counts across all worlds. + private fun onlineCountInWorld(worldName: String): Int { + return if ( + worldName.equals("world", ignoreCase = true) || + worldName.equals("world_nether", ignoreCase = true) || + worldName.equals("world_the_end", ignoreCase = true) + ) { + Bukkit.getOnlinePlayers().count() + } else { + Bukkit.getWorld(worldName)?.players?.size ?: 0 + } + } // Gets the lobby/game label for a world, e.g. "HB1 Lobby" or "HB1 Game". private fun getGameLabel(worldName: String): String? { -- 2.47.3 From 824b0bd370c47b6379c001c5318405545771a0ab Mon Sep 17 00:00:00 2001 From: Sven Date: Fri, 28 Aug 2026 17:13:35 +0200 Subject: [PATCH 06/11] Fix disallowed link still being sent to Discord --- src/main/kotlin/nl/skbotnl/chatog/chatsystem/ChatSystem.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/nl/skbotnl/chatog/chatsystem/ChatSystem.kt b/src/main/kotlin/nl/skbotnl/chatog/chatsystem/ChatSystem.kt index 86ac932..f60748e 100644 --- a/src/main/kotlin/nl/skbotnl/chatog/chatsystem/ChatSystem.kt +++ b/src/main/kotlin/nl/skbotnl/chatog/chatsystem/ChatSystem.kt @@ -46,11 +46,12 @@ internal abstract class ChatSystem { val discordPlayerPartString = listOfNotNull(prefix, ChatUtil.getPlayerPartString(player, includeSuffix = true)).joinToString(" | ") - sendDiscordMessage(resolveChatItems(text), discordPlayerPartString, player.uniqueId) - + // This must be above sendDiscordMessage because processText will exit if a disallowed link is sent val messageComponent = ChatUtil.processText(text, player)?.color(PlayerUtils.getMessageColor(player.uniqueId)) ?: return + sendDiscordMessage(resolveChatItems(text), discordPlayerPartString, player.uniqueId) + val chatComponent = UtilitiesOG.trueogColorize( ChatUtil.legacyToMm("$playerPartString${PlayerUtils.getSuffix(player.uniqueId)} &7> ") -- 2.47.3 From 7880d2ef75835950bfe1b2ea855a958e13b88283 Mon Sep 17 00:00:00 2001 From: NotAlexNoyle Date: Mon, 14 Sep 2026 02:01:48 -0400 Subject: [PATCH 07/11] Update Utilities-OG API --- libs/Vanish-OG | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/Vanish-OG b/libs/Vanish-OG index 64d652a..8d04fc0 160000 --- a/libs/Vanish-OG +++ b/libs/Vanish-OG @@ -1 +1 @@ -Subproject commit 64d652a6760ab5fc6356e1e65207027eb1bae36c +Subproject commit 8d04fc0cf7e5648e1c1d6b2da1384bd4856196a8 -- 2.47.3 From b0d86fefaf28581720305417245fdcd22338a692 Mon Sep 17 00:00:00 2001 From: NotAlexNoyle Date: Mon, 14 Sep 2026 02:07:56 -0400 Subject: [PATCH 08/11] Update Utilities-OG API --- libs/Vanish-OG | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/Vanish-OG b/libs/Vanish-OG index 64d652a..8d04fc0 160000 --- a/libs/Vanish-OG +++ b/libs/Vanish-OG @@ -1 +1 @@ -Subproject commit 64d652a6760ab5fc6356e1e65207027eb1bae36c +Subproject commit 8d04fc0cf7e5648e1c1d6b2da1384bd4856196a8 -- 2.47.3 From e6bf3958757c3803d1888623950522b5f71bb523 Mon Sep 17 00:00:00 2001 From: NotAlexNoyle Date: Sun, 18 Oct 2026 15:00:46 -0400 Subject: [PATCH 09/11] Update dependencies --- .gitmodules | 3 +++ build.gradle.kts | 4 ++-- libs/Utilities-OG | 1 + settings.gradle.kts | 8 +++++++- 4 files changed, 13 insertions(+), 3 deletions(-) create mode 160000 libs/Utilities-OG diff --git a/.gitmodules b/.gitmodules index 0150a95..17f1d5b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "libs/Vanish-OG"] path = libs/Vanish-OG url = https://github.com/true-og/Vanish-OG +[submodule "libs/Utilities-OG"] + path = libs/Utilities-OG + url = https://github.com/true-og/Utilities-OG diff --git a/build.gradle.kts b/build.gradle.kts index 8a2e072..e6764e4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -54,8 +54,8 @@ dependencies { compileOnly( files("libs/AnnouncerPlus-1.4.1.jar") ) // Import AnnouncerPlus API (binary-compatible with AnnouncerPlus-OG API). - compileOnlyApi("net.trueog.utilities-og:Utilities-OG") // Import TrueOG Network Utilities-OG Java API (from source). - compileOnlyApi("de.myzelyam.vanishog:Vanish-OG") // Import TrueOG Network Vanish-OG Java API (from source). + compileOnlyApi(project(":libs:Utilities-OG")) // Import TrueOG Network Utilities-OG Java API (from source). + compileOnlyApi(project(":libs:Vanish-OG")) // Import TrueOG Network Vanish-OG Java API (from source). compileOnly("net.luckperms:api:5.5") // Import LuckPerms API. compileOnly( "io.github.miniplaceholders:miniplaceholders-api:2.2.3" diff --git a/libs/Utilities-OG b/libs/Utilities-OG new file mode 160000 index 0000000..1439e99 --- /dev/null +++ b/libs/Utilities-OG @@ -0,0 +1 @@ +Subproject commit 1439e999f605eff4837f669f549d655fc189c06a diff --git a/settings.gradle.kts b/settings.gradle.kts index 9c7b182..b4cc8f2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,4 +4,10 @@ ProcessBuilder("sh", "bootstrap.sh").directory(rootDir).inheritIO().start().let if (it.waitFor() != 0) throw GradleException("bootstrap.sh failed") } -file("libs").listFiles()?.filter { it.isDirectory && !it.name.startsWith(".") }?.forEach { dir -> includeBuild(dir) } +file("libs") + .listFiles() + ?.filter { it.isDirectory && !it.name.startsWith(".") } + ?.forEach { dir -> + include(":libs:${dir.name}") + project(":libs:${dir.name}").projectDir = dir + } -- 2.47.3 From 3735ac2684a4395a4dd1f86838b0da1897b5d4b4 Mon Sep 17 00:00:00 2001 From: NotAlexNoyle Date: Sun, 18 Oct 2026 15:02:45 -0400 Subject: [PATCH 10/11] Update Vanish-OG --- libs/Vanish-OG | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/Vanish-OG b/libs/Vanish-OG index 8d04fc0..03b955f 160000 --- a/libs/Vanish-OG +++ b/libs/Vanish-OG @@ -1 +1 @@ -Subproject commit 8d04fc0cf7e5648e1c1d6b2da1384bd4856196a8 +Subproject commit 03b955fd2a41769d0bca120e020983be54bad175 -- 2.47.3 From 9a0ba40039287c35488319947385051a8ec11827 Mon Sep 17 00:00:00 2001 From: NotAlexNoyle Date: Sun, 18 Oct 2026 15:04:33 -0400 Subject: [PATCH 11/11] Update Vanish-OG --- libs/Vanish-OG | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/Vanish-OG b/libs/Vanish-OG index 8d04fc0..03b955f 160000 --- a/libs/Vanish-OG +++ b/libs/Vanish-OG @@ -1 +1 @@ -Subproject commit 8d04fc0cf7e5648e1c1d6b2da1384bd4856196a8 +Subproject commit 03b955fd2a41769d0bca120e020983be54bad175 -- 2.47.3