diff --git a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Bridge.java b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Bridge.java index 7c758ce..9d54ee0 100644 --- a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Bridge.java +++ b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Bridge.java @@ -37,8 +37,6 @@ public final class Bridge { private final ScheduledExecutorService ticker = Executors.newSingleThreadScheduledExecutor( Thread.ofPlatform().name("bridge-tick").factory()); private final Announcer announcer; - /** Wie lange nach dem letzten Paket jemand noch als "spricht" gilt. */ - private static final long SPEAKING_MILLIS = 400; private volatile List roomInfos = List.of(); @@ -63,14 +61,6 @@ public final class Bridge { this.teamspeak = config.teamspeak().enabled() ? new TeamspeakSide(config.teamspeak(), config.dataDir(), List.copyOf(rooms.values()), updater) : null; - for (Room room : rooms.values()) { - DiscordRoomBot bot = discordBots.get(room.key()); - room.setNameResolver((side, id) -> switch (side) { - case TEAMSPEAK -> teamspeak != null ? teamspeak.nickname(Integer.parseInt(id)) : id; - case DISCORD -> bot != null ? bot.name(Long.parseLong(id)) : id; - case MINECRAFT -> id; - }); - } this.announcer = new Announcer(plugin, teamspeak, ticker); roomInfos = buildInfos(); } @@ -82,7 +72,7 @@ public final class Bridge { if (teamspeak != null) teamspeak.start(); discordBots.values().forEach(DiscordRoomBot::start); ticker.scheduleAtFixedRate(this::safeTick, 1, 1, TimeUnit.SECONDS); - ticker.scheduleAtFixedRate(this::sendSpeakers, 500, 500, TimeUnit.MILLISECONDS); + ticker.scheduleAtFixedRate(this::sendParticipants, 2, 2, TimeUnit.SECONDS); } private void safeTick() { @@ -154,16 +144,27 @@ public final class Bridge { return roster; } - /** Actionbar im Spiel: wer aus Discord/TeamSpeak gerade spricht. */ - private void sendSpeakers() { + /** + * Wer aus Discord/TeamSpeak im Raum ist, ans Plugin: Es trägt sie in die Gruppenanzeige des Voicechats ein. + * Alle 2 s komplett, damit ein verlorenes UDP-Paket nichts ausmacht. + */ + private void sendParticipants() { try { for (Room room : rooms.values()) { - if (!room.active()) continue; - List speakers = room.externalSpeakers(SPEAKING_MILLIS); - if (!speakers.isEmpty()) plugin.sendSpeakers(room.key(), speakers); + DiscordRoomBot bot = discordBots.get(room.key()); + List members = new ArrayList<>(); + if (teamspeak != null && teamspeak.ready()) { + teamspeak.participants(room).forEach((id, name) -> + members.add(new Message.Member(room.sourceUuid("ts:" + id), name + " [TeamSpeak]"))); + } + if (bot != null && bot.ready()) { + bot.participants().forEach((id, name) -> + members.add(new Message.Member(room.sourceUuid("dc:" + id), name + " [Discord]"))); + } + plugin.sendParticipants(room.key(), members); } } catch (RuntimeException e) { - LOG.warn("Sprecher-Anzeige fehlgeschlagen", e); + LOG.warn("Teilnehmer-Abgleich fehlgeschlagen", e); } } diff --git a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/PluginLink.java b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/PluginLink.java index dacbaa2..da07fdf 100644 --- a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/PluginLink.java +++ b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/PluginLink.java @@ -61,9 +61,9 @@ final class PluginLink { if (target != null && alive()) send(new Message.Notice(room, text), target); } - void sendSpeakers(String room, List names) { + void sendParticipants(String room, List members) { SocketAddress target = plugin; - if (target != null && alive()) send(new Message.Speakers(room, names), target); + if (target != null && alive()) send(new Message.Participants(room, members), target); } void sendAudio(String room, UUID source, byte[] opus) { @@ -121,7 +121,7 @@ final class PluginLink { } case Message.Rooms ignored -> {} case Message.Notice ignored -> {} - case Message.Speakers ignored -> {} + case Message.Participants ignored -> {} } } diff --git a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Room.java b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Room.java index aee85c7..d015e52 100644 --- a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Room.java +++ b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Room.java @@ -14,7 +14,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; -import java.util.function.BiFunction; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; @@ -36,9 +35,6 @@ public final class Room { private final Mixer toDiscord = new Mixer(); private final Map sources = new ConcurrentHashMap<>(); - /** Anzeigename eines Sprechers (Seite, ID ohne Präfix), für die Actionbar. */ - private volatile BiFunction names = (side, id) -> id; - /** Wohin Frames für Minecraft gehen (Plugin-Verbindung). */ private volatile MinecraftSink minecraft = (room, source, opus) -> {}; @@ -59,13 +55,12 @@ public final class Room { final FrameSplitter splitter = new FrameSplitter(); OpusEncoder minecraftEncoder; volatile long lastSeen; - volatile String name; int lastSequence = -1; Source(Side side, String key) { this.side = side; this.key = key; - this.uuid = UUID.nameUUIDFromBytes(("voicebridge:" + config.key() + ":" + key).getBytes(StandardCharsets.UTF_8)); + this.uuid = sourceUuid(key); } } @@ -83,15 +78,13 @@ public final class Room { public Mixer toDiscord() { return toDiscord; } public void setMinecraftSink(MinecraftSink sink) { this.minecraft = sink; } - public void setNameResolver(BiFunction names) { this.names = names; } - /** Wer aus Discord/TeamSpeak in den letzten {@code millis} Audio geschickt hat, als "Name|Kürzel". */ - public List externalSpeakers(long millis) { - long since = System.currentTimeMillis() - millis; - return sources.values().stream() - .filter(s -> s.side != Side.MINECRAFT && s.lastSeen >= since) - .map(s -> s.name + "|" + (s.side == Side.DISCORD ? "DC" : "TS")) - .sorted().toList(); + /** + * Feste UUID eines Sprechers, z. B. {@code ts:5} oder {@code dc:1234}. Unter ihr läuft sein Audio im Spiel, + * und unter ihr erscheint er auch in der Gruppenanzeige des Voicechats. + */ + public UUID sourceUuid(String key) { + return UUID.nameUUIDFromBytes(("voicebridge:" + config.key() + ":" + key).getBytes(StandardCharsets.UTF_8)); } public static String label(Side side) { @@ -132,7 +125,6 @@ public final class Room { if (!active || opus.length == 0) return; Source source = sources.computeIfAbsent(key, k -> new Source(side, k)); source.lastSeen = System.currentTimeMillis(); - if (source.name == null) source.name = names.apply(side, key.substring(3)); List frames = new ArrayList<>(2); try { diff --git a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/discord/DiscordRoomBot.java b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/discord/DiscordRoomBot.java index 3e0740e..07a3df0 100644 --- a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/discord/DiscordRoomBot.java +++ b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/discord/DiscordRoomBot.java @@ -152,13 +152,6 @@ public final class DiscordRoomBot implements AudioSendHandler, AudioReceiveHandl return result; } - /** Anzeigename eines Users, für die Sprecher-Anzeige. */ - public String name(long userId) { - Guild guild = this.guild; - Member member = guild == null ? null : guild.getMemberById(userId); - return member == null ? "?" : member.getEffectiveName(); - } - /** Ob der Bot eingeloggt ist und seinen Channel kennt. */ public boolean ready() { return guild != null && channelId != 0; diff --git a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/teamspeak/TeamspeakSide.java b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/teamspeak/TeamspeakSide.java index 3dac15b..17bd05b 100644 --- a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/teamspeak/TeamspeakSide.java +++ b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/teamspeak/TeamspeakSide.java @@ -89,12 +89,6 @@ public final class TeamspeakSide { return result; } - /** Nickname eines Clients, für die Sprecher-Anzeige. */ - public String nickname(int clientId) { - TsClient client = clients.get(clientId); - return client == null ? "?" : client.name(); - } - /** Ob der Raum-Client gerade im Channel ist. */ public boolean roomConnected(Room room) { TeamspeakRoomClient client = roomClients.get(room.key()); diff --git a/plugin/build.gradle b/plugin/build.gradle index e5aa72a..d4f1f48 100644 --- a/plugin/build.gradle +++ b/plugin/build.gradle @@ -47,3 +47,14 @@ tasks.register('uploadPlugin') { }.result.get() } } + +dependencies { + testImplementation platform('org.junit:junit-bom:5.13.4') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'io.papermc.paper:paper-api:26.2.build.129-stable' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/plugin/src/main/java/eu/mhsl/voicebridge/plugin/ActionbarCommand.java b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/ActionbarCommand.java deleted file mode 100644 index 14bee94..0000000 --- a/plugin/src/main/java/eu/mhsl/voicebridge/plugin/ActionbarCommand.java +++ /dev/null @@ -1,49 +0,0 @@ -package eu.mhsl.voicebridge.plugin; - -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.NamespacedKey; -import org.bukkit.command.Command; -import org.bukkit.command.CommandSender; -import org.bukkit.command.TabExecutor; -import org.bukkit.entity.Player; -import org.bukkit.persistence.PersistentDataType; -import org.bukkit.plugin.Plugin; -import org.jetbrains.annotations.NotNull; - -import java.util.List; - -/** {@code /crosstalk actionbar}: die Sprecher-Anzeige für sich selbst aus- und einschalten. */ -final class ActionbarCommand implements TabExecutor { - private static NamespacedKey key; - - static void init(Plugin plugin) { - key = new NamespacedKey(plugin, "actionbar_off"); - } - - /** Main-Thread: ob der Spieler die Actionbar sehen will (Standard: ja). */ - static boolean enabled(Player player) { - return !player.getPersistentDataContainer().has(key, PersistentDataType.BYTE); - } - - @Override - public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, String @NotNull [] args) { - if (!(sender instanceof Player player)) { - sender.sendMessage("Nur für Spieler."); - return true; - } - if (args.length != 1 || !args[0].equalsIgnoreCase("actionbar")) return false; - - boolean nowEnabled = !enabled(player); - if (nowEnabled) player.getPersistentDataContainer().remove(key); - else player.getPersistentDataContainer().set(key, PersistentDataType.BYTE, (byte) 1); - player.sendMessage(Component.text("Anzeige, wer aus Discord/TeamSpeak spricht: " - + (nowEnabled ? "an" : "aus"), NamedTextColor.GRAY)); - return true; - } - - @Override - public List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, String @NotNull [] args) { - return args.length == 1 ? List.of("actionbar") : List.of(); - } -} diff --git a/plugin/src/main/java/eu/mhsl/voicebridge/plugin/BridgeVoicechat.java b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/BridgeVoicechat.java index 93d662f..4c83b82 100644 --- a/plugin/src/main/java/eu/mhsl/voicebridge/plugin/BridgeVoicechat.java +++ b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/BridgeVoicechat.java @@ -115,6 +115,7 @@ final class BridgeVoicechat implements VoicechatPlugin { for (CrosstalkRoom room : rooms.values()) { List members = byGroup.getOrDefault(room.groupId(), List.of()); room.setMembers(members); + room.syncExternals(plugin); room.dropIdleSpeakers(); roster.put(room.key(), members.stream().map(c -> { UUID id = c.getPlayer().getUuid(); @@ -139,32 +140,16 @@ final class BridgeVoicechat implements VoicechatPlugin { .append(Component.text(notice.text(), NamedTextColor.GRAY)); room.memberPlayers().forEach(player -> player.sendMessage(text)); }); - case Message.Speakers speakers -> Bukkit.getScheduler().runTask(plugin, () -> { - CrosstalkRoom room = rooms.get(speakers.room()); - if (room == null || speakers.names().isEmpty() || !plugin.getConfig().getBoolean("actionbar", true)) return; - Component text = speakersLine(speakers.names()); - room.memberPlayers().stream().filter(ActionbarCommand::enabled).forEach(player -> player.sendActionBar(text)); + case Message.Participants participants -> Bukkit.getScheduler().runTask(plugin, () -> { + CrosstalkRoom room = rooms.get(participants.room()); + if (room == null) return; + room.setExternals(participants.members()); + room.syncExternals(plugin); }); case Message.Hello ignored -> {} } } - /** Unauffällige Actionbar: "♪ Anna DC · Bob TS", höchstens drei Namen, Rest als "+N". */ - static Component speakersLine(List speakers) { - int shown = Math.min(3, speakers.size()); - Component line = Component.text("♪ ", NamedTextColor.DARK_GRAY); - for (int i = 0; i < shown; i++) { - String entry = speakers.get(i); - int split = entry.lastIndexOf('|'); - String name = split < 0 ? entry : entry.substring(0, split); - String tag = split < 0 ? "" : " " + entry.substring(split + 1); - if (i > 0) line = line.append(Component.text(" · ", NamedTextColor.DARK_GRAY)); - line = line.append(Component.text(name, NamedTextColor.GRAY)).append(Component.text(tag, NamedTextColor.DARK_GRAY)); - } - if (speakers.size() > shown) line = line.append(Component.text(" +" + (speakers.size() - shown), NamedTextColor.DARK_GRAY)); - return line; - } - /** Main-Thread: Gruppen an die Räume der Bridge angleichen. */ private void syncRooms(List infos) { VoicechatServerApi api = this.api; @@ -209,6 +194,8 @@ final class BridgeVoicechat implements VoicechatPlugin { rooms.remove(room.key()); roomsByGroup.remove(room.groupId()); room.dropAllSpeakers(); + room.setExternals(List.of()); + room.syncExternals(plugin); if (api != null) api.removeGroup(room.groupId()); plugin.getLogger().info("Crosstalk-Gruppe entfernt: " + room.name()); } diff --git a/plugin/src/main/java/eu/mhsl/voicebridge/plugin/CrosstalkRoom.java b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/CrosstalkRoom.java index 88e827f..70d7bf3 100644 --- a/plugin/src/main/java/eu/mhsl/voicebridge/plugin/CrosstalkRoom.java +++ b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/CrosstalkRoom.java @@ -8,7 +8,12 @@ import eu.mhsl.voicebridge.protocol.Message; import org.bukkit.Bukkit; import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; + import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Set; import java.util.List; import java.util.Map; import java.util.Objects; @@ -20,6 +25,7 @@ import java.util.UUID; */ final class CrosstalkRoom { private static final long SPEAKER_IDLE_MILLIS = 1000; + private static final long FULL_SYNC_MILLIS = 30_000; private final VoicechatServerApi api; private final String key; @@ -27,6 +33,11 @@ final class CrosstalkRoom { private final UUID groupId; private final Map speakers = new HashMap<>(); + /** Leute aus Discord/TeamSpeak (UUID ihres Audios → Anzeigename), laut Bridge. */ + private Map externals = Map.of(); + /** Was jeder Spieler davon schon in seiner Voicechat-Anzeige hat. */ + private final Map> shown = new HashMap<>(); + private long lastFullSync; private List members = List.of(); private volatile Message.RoomInfo info; @@ -50,6 +61,47 @@ final class CrosstalkRoom { return current != null && current.active(); } + synchronized void setExternals(List members) { + Map map = new LinkedHashMap<>(); + for (Message.Member member : members) map.put(member.id(), member.name()); + externals = map; + } + + /** + * Main-Thread: Die Leute aus Discord/TeamSpeak als Mitglieder in die Gruppenanzeige der Spieler eintragen + * (mit Sprech-Symbol, weil ihr Audio unter derselben UUID kommt). Nur Änderungen, alle 30 s alles. + */ + synchronized void syncExternals(Plugin plugin) { + long now = System.currentTimeMillis(); + boolean full = now - lastFullSync >= FULL_SYNC_MILLIS; + if (full) lastFullSync = now; + + Set viewers = new HashSet<>(); + for (Player player : memberPlayers()) { + viewers.add(player.getUniqueId()); + Map have = shown.computeIfAbsent(player.getUniqueId(), k -> new HashMap<>()); + for (Map.Entry e : externals.entrySet()) { + if (full || !e.getValue().equals(have.get(e.getKey()))) { + VoicechatStates.sendState(plugin, player, e.getKey(), e.getValue(), groupId); + have.put(e.getKey(), e.getValue()); + } + } + have.keySet().removeIf(id -> { + if (externals.containsKey(id)) return false; + VoicechatStates.sendRemove(plugin, player, id); + return true; + }); + } + + // Wer die Gruppe verlassen hat, bekommt die Einträge wieder entfernt + shown.entrySet().removeIf(e -> { + if (viewers.contains(e.getKey())) return false; + Player player = Bukkit.getPlayer(e.getKey()); + if (player != null) e.getValue().keySet().forEach(id -> VoicechatStates.sendRemove(plugin, player, id)); + return true; + }); + } + /** Main-Thread: die Spieler in der Gruppe. */ synchronized List memberPlayers() { return members.stream().map(c -> Bukkit.getPlayer(c.getPlayer().getUuid())).filter(Objects::nonNull).toList(); diff --git a/plugin/src/main/java/eu/mhsl/voicebridge/plugin/VoiceBridgePlugin.java b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/VoiceBridgePlugin.java index 3d6f3a3..50befa1 100644 --- a/plugin/src/main/java/eu/mhsl/voicebridge/plugin/VoiceBridgePlugin.java +++ b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/VoiceBridgePlugin.java @@ -22,9 +22,7 @@ public final class VoiceBridgePlugin extends JavaPlugin { InetSocketAddress bridge = new InetSocketAddress( getConfig().getString("bridge.host", "127.0.0.1"), getConfig().getInt("bridge.port", 24470)); - ActionbarCommand.init(this); - var command = getCommand("crosstalk"); - if (command != null) command.setExecutor(new ActionbarCommand()); + VoicechatStates.register(this); voicechat = new BridgeVoicechat(this, bridge); service.registerPlugin(voicechat); } diff --git a/plugin/src/main/java/eu/mhsl/voicebridge/plugin/VoicechatStates.java b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/VoicechatStates.java new file mode 100644 index 0000000..1958618 --- /dev/null +++ b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/VoicechatStates.java @@ -0,0 +1,80 @@ +package eu.mhsl.voicebridge.plugin; + +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +/** + * Trägt erfundene Teilnehmer (Leute aus Discord/TeamSpeak) in die Gruppenanzeige des Voicechat-Clients ein. + * + *

Das ist kein offizieller API-Weg: Wir schicken dieselben Plugin-Messages, mit denen Simple Voice Chat + * dem Client die Spielerliste mitteilt ({@code voicechat:state}, {@code voicechat:remove_state}, Format aus + * {@code PlayerState#toBytes} in Version 2.6.x). Ändert ein Voicechat-Update das Format, fehlen nur die + * Einträge, das Audio läuft weiter über die API. + */ +final class VoicechatStates { + static final String STATE_CHANNEL = "voicechat:state"; + static final String REMOVE_CHANNEL = "voicechat:remove_state"; + + private VoicechatStates() {} + + static void register(Plugin plugin) { + plugin.getServer().getMessenger().registerOutgoingPluginChannel(plugin, STATE_CHANNEL); + plugin.getServer().getMessenger().registerOutgoingPluginChannel(plugin, REMOVE_CHANNEL); + } + + static void sendState(Plugin plugin, Player viewer, UUID id, String name, UUID group) { + viewer.sendPluginMessage(plugin, STATE_CHANNEL, state(id, name, group)); + } + + static void sendRemove(Plugin plugin, Player viewer, UUID id) { + viewer.sendPluginMessage(plugin, REMOVE_CHANNEL, uuid(id)); + } + + /** {@code PlayerState#toBytes}: disabled, disconnected, UUID, Name (VarInt-Länge + UTF-8), optional Gruppe. */ + static byte[] state(UUID id, String name, UUID group) { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(64); + try (DataOutputStream out = new DataOutputStream(bytes)) { + out.writeBoolean(false); + out.writeBoolean(false); + out.writeLong(id.getMostSignificantBits()); + out.writeLong(id.getLeastSignificantBits()); + byte[] utf = name.getBytes(StandardCharsets.UTF_8); + writeVarInt(out, utf.length); + out.write(utf); + out.writeBoolean(group != null); + if (group != null) { + out.writeLong(group.getMostSignificantBits()); + out.writeLong(group.getLeastSignificantBits()); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return bytes.toByteArray(); + } + + static byte[] uuid(UUID id) { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(16); + try (DataOutputStream out = new DataOutputStream(bytes)) { + out.writeLong(id.getMostSignificantBits()); + out.writeLong(id.getLeastSignificantBits()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return bytes.toByteArray(); + } + + private static void writeVarInt(DataOutputStream out, int value) throws IOException { + while ((value & ~0x7F) != 0) { + out.writeByte((value & 0x7F) | 0x80); + value >>>= 7; + } + out.writeByte(value); + } +} diff --git a/plugin/src/main/resources/config.yml b/plugin/src/main/resources/config.yml index 31eb3c4..bd8de9d 100644 --- a/plugin/src/main/resources/config.yml +++ b/plugin/src/main/resources/config.yml @@ -2,7 +2,3 @@ bridge: host: 127.0.0.1 port: 24470 - -# Actionbar "♪ Anna DC · Bob TS" mit den gerade sprechenden Leuten aus Discord/TeamSpeak. -# Jeder Spieler kann sie zusätzlich mit /crosstalk actionbar für sich aus- und einschalten. -actionbar: true diff --git a/plugin/src/main/resources/plugin.yml b/plugin/src/main/resources/plugin.yml index 4b493f4..a9c39dd 100644 --- a/plugin/src/main/resources/plugin.yml +++ b/plugin/src/main/resources/plugin.yml @@ -6,12 +6,3 @@ depend: [ voicechat ] authors: [ MineTec ] description: Verbindet Simple-Voice-Chat-Gruppen mit Discord und TeamSpeak website: mhsl.eu -commands: - crosstalk: - description: Einstellungen für die Crosstalk-Gruppen - usage: "/crosstalk actionbar" - permission: voicebridge.crosstalk -permissions: - voicebridge.crosstalk: - description: /crosstalk benutzen - default: true diff --git a/plugin/src/test/java/eu/mhsl/voicebridge/plugin/VoicechatStatesTest.java b/plugin/src/test/java/eu/mhsl/voicebridge/plugin/VoicechatStatesTest.java new file mode 100644 index 0000000..824db8b --- /dev/null +++ b/plugin/src/test/java/eu/mhsl/voicebridge/plugin/VoicechatStatesTest.java @@ -0,0 +1,47 @@ +package eu.mhsl.voicebridge.plugin; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** Liest wie {@code PlayerState#fromBytes} aus Simple Voice Chat 2.6.x. */ +class VoicechatStatesTest { + + @Test + void stateMatchesVoicechatFormat() throws IOException { + UUID id = UUID.randomUUID(); + UUID group = UUID.randomUUID(); + String name = "Änna [Discord]".repeat(10); // > 127 Bytes: VarInt mit zwei Bytes + DataInputStream in = new DataInputStream(new ByteArrayInputStream(VoicechatStates.state(id, name, group))); + + assertFalse(in.readBoolean()); + assertFalse(in.readBoolean()); + assertEquals(id, new UUID(in.readLong(), in.readLong())); + int length = 0, shift = 0, b; + do { + b = in.readUnsignedByte(); + length |= (b & 0x7F) << shift; + shift += 7; + } while ((b & 0x80) != 0); + byte[] utf = new byte[length]; + in.readFully(utf); + assertEquals(name, new String(utf, StandardCharsets.UTF_8)); + assertTrue(in.readBoolean()); + assertEquals(group, new UUID(in.readLong(), in.readLong())); + assertEquals(0, in.available()); + } + + @Test + void removeIsJustTheUuid() throws IOException { + UUID id = UUID.randomUUID(); + DataInputStream in = new DataInputStream(new ByteArrayInputStream(VoicechatStates.uuid(id))); + assertEquals(id, new UUID(in.readLong(), in.readLong())); + assertEquals(0, in.available()); + } +} diff --git a/protocol/src/main/java/eu/mhsl/voicebridge/protocol/Message.java b/protocol/src/main/java/eu/mhsl/voicebridge/protocol/Message.java index acbb695..ecdebd8 100644 --- a/protocol/src/main/java/eu/mhsl/voicebridge/protocol/Message.java +++ b/protocol/src/main/java/eu/mhsl/voicebridge/protocol/Message.java @@ -28,6 +28,9 @@ public sealed interface Message { /** Bridge → Plugin: Chat-Hinweis an alle in der Gruppe, z. B. wer dazugekommen ist. */ record Notice(String room, String text) implements Message {} - /** Bridge → Plugin: wer aus Discord/TeamSpeak gerade spricht (für die Actionbar), je "Name|Kürzel". */ - record Speakers(String room, List names) implements Message {} + /** + * Bridge → Plugin: wer aus Discord/TeamSpeak im Raum ist. {@code id} ist dieselbe UUID wie die + * {@code source} seines Audios, damit der Voicechat-Client das Sprech-Symbol zuordnen kann. + */ + record Participants(String room, List members) implements Message {} } diff --git a/protocol/src/main/java/eu/mhsl/voicebridge/protocol/Protocol.java b/protocol/src/main/java/eu/mhsl/voicebridge/protocol/Protocol.java index 26a1a05..23b18f7 100644 --- a/protocol/src/main/java/eu/mhsl/voicebridge/protocol/Protocol.java +++ b/protocol/src/main/java/eu/mhsl/voicebridge/protocol/Protocol.java @@ -14,7 +14,7 @@ import java.util.UUID; public final class Protocol { /** Bei inkompatiblen Änderungen am Format hochzählen. */ - public static final int VERSION = 2; + public static final int VERSION = 3; public static final int DEFAULT_PORT = 24470; public static final int MAX_DATAGRAM = 2048; @@ -22,7 +22,7 @@ public final class Protocol { private static final byte ROOMS = 2; private static final byte AUDIO = 3; private static final byte NOTICE = 4; - private static final byte SPEAKERS = 5; + private static final byte PARTICIPANTS = 5; private Protocol() {} @@ -66,11 +66,14 @@ public final class Protocol { out.writeUTF(notice.room()); out.writeUTF(notice.text()); } - case Message.Speakers speakers -> { - out.writeByte(SPEAKERS); - out.writeUTF(speakers.room()); - out.writeShort(speakers.names().size()); - for (String name : speakers.names()) out.writeUTF(name); + case Message.Participants participants -> { + out.writeByte(PARTICIPANTS); + out.writeUTF(participants.room()); + out.writeShort(participants.members().size()); + for (Message.Member member : participants.members()) { + writeUuid(out, member.id()); + out.writeUTF(member.name()); + } } } } catch (IOException e) { @@ -107,12 +110,12 @@ public final class Protocol { } case AUDIO -> new Message.Audio(in.readUTF(), readUuid(in), in.readAllBytes()); case NOTICE -> new Message.Notice(in.readUTF(), in.readUTF()); - case SPEAKERS -> { + case PARTICIPANTS -> { String room = in.readUTF(); int size = in.readUnsignedShort(); - List names = new ArrayList<>(size); - for (int i = 0; i < size; i++) names.add(in.readUTF()); - yield new Message.Speakers(room, names); + List members = new ArrayList<>(size); + for (int i = 0; i < size; i++) members.add(new Message.Member(readUuid(in), in.readUTF())); + yield new Message.Participants(room, members); } default -> throw new IOException("Unbekannter Nachrichtentyp " + type); }; diff --git a/protocol/src/test/java/eu/mhsl/voicebridge/protocol/ProtocolTest.java b/protocol/src/test/java/eu/mhsl/voicebridge/protocol/ProtocolTest.java index f1ed722..7ab3a39 100644 --- a/protocol/src/test/java/eu/mhsl/voicebridge/protocol/ProtocolTest.java +++ b/protocol/src/test/java/eu/mhsl/voicebridge/protocol/ProtocolTest.java @@ -41,11 +41,12 @@ class ProtocolTest { } @Test - void noticeAndSpeakers() throws IOException { + void noticeAndParticipants() throws IOException { Message.Notice notice = new Message.Notice("crosstalk-1", "Anna (Discord) ist dazugekommen."); assertEquals(notice, roundTrip(notice)); - Message.Speakers speakers = new Message.Speakers("crosstalk-1", List.of("Anna (Discord)", "Bob (TeamSpeak)")); - assertEquals(speakers, roundTrip(speakers)); + Message.Participants participants = new Message.Participants("crosstalk-1", + List.of(new Message.Member(UUID.randomUUID(), "Anna [Discord]"))); + assertEquals(participants, roundTrip(participants)); } @Test