diff --git a/README.md b/README.md index 1d0d1a0..49d6e70 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,20 @@ Events are delivered at most once. A proxy that reconnects does not receive even Messages are shown in English or German, following the player's client language. +### Languages and message overrides + +The plugin ships English and German. A player sees the bundle for their client locale: `de_DE`, then `de`, then `en`. A key that is missing in the chosen bundle is shown in English, so a partial translation never shows a raw key. + +To change or add messages without a new release, put a file named `messages_.properties` into `plugins/quelea/lang/` on the proxy (for example in the proxy template, next to `config.properties`): + +- **Override single messages:** `messages_de.properties` with only the keys to change. Every other German text stays built in. +- **Add a language:** `messages_fr.properties` translates the keys it wants; the rest of the messages are English until they are translated. +- **Regional variants:** `messages_de_AT.properties` is used for Austrian clients and falls back to `de`, then `en`. + +The files are read once at proxy start-up, so restart the proxy after a change. Each value is a MiniMessage template. Copy the keys from the built-in `messages_en.properties` in the plugin jar (`lang/`), and keep the placeholders (``, ``, `` and so on) as they are. If a template does not parse, the proxy logs a warning naming the key and language, and that message is shown in English. + +The colour guide at the top of `messages_en.properties` explains which colour means what (commands, arguments, errors, hints, the leader's star). + ## Configuration ### Backend (environment variables) diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/QueleaVelocity.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/QueleaVelocity.java index 9195a4a..84974ca 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/QueleaVelocity.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/QueleaVelocity.java @@ -87,7 +87,7 @@ public void onProxyInitialize(ProxyInitializeEvent event) { executor = Executors.newVirtualThreadPerTaskExecutor(); httpClient = HttpQueleaGateway.newClient(); QueleaGateway gateway = new HttpQueleaGateway(config.url(), httpClient, tokens); - Messages messages = Messages.load(); + Messages messages = Messages.load(dataDirectory.resolve("lang"), logger); FollowPolicy follow = FollowPolicy.of(config.lobbyPattern(), config.followMode()); VelocityLocalPlayers localPlayers = new VelocityLocalPlayers(proxy); diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/HelpText.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/HelpText.java new file mode 100644 index 0000000..15834fa --- /dev/null +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/HelpText.java @@ -0,0 +1,52 @@ +package net.onelitefeather.quelea.velocity.command; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.onelitefeather.quelea.velocity.i18n.Messages; + +import java.util.List; +import java.util.Locale; + +/** + * The {@code /party} help: a title, a separator and one line per command. Clicking a line suggests its command in + * the chat input; hovering explains it in the player's language ({@code command.help..hover}). + */ +final class HelpText { + + /** A help line: the bundle name, and the command the click suggests. */ + record Entry(String name, String suggestion) { + } + + static final List ENTRIES = List.of( + new Entry("create", "/party create"), + new Entry("invite", "/party invite "), + new Entry("accept", "/party accept "), + new Entry("deny", "/party deny "), + new Entry("leave", "/party leave"), + new Entry("list", "/party list"), + new Entry("kick", "/party kick "), + new Entry("promote", "/party promote "), + new Entry("disband", "/party disband"), + new Entry("warp", "/party warp"), + new Entry("chat", "/pc ")); + + private HelpText() { + } + + static Component of(Messages messages, Locale locale) { + Component help = messages.message(locale, "command.help.title") + .append(Component.newline()) + .append(messages.line(locale, "command.help.separator")); + for (Entry entry : ENTRIES) { + help = help.append(Component.newline()).append(line(messages, locale, entry)); + } + return help; + } + + private static Component line(Messages messages, Locale locale, Entry entry) { + return messages.line(locale, "command.help." + entry.name()) + .clickEvent(ClickEvent.suggestCommand(entry.suggestion())) + .hoverEvent(HoverEvent.showText(messages.line(locale, "command.help." + entry.name() + ".hover"))); + } +} diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyActions.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyActions.java index 9f1d131..503d3b7 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyActions.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyActions.java @@ -14,7 +14,7 @@ import java.util.List; import java.util.Locale; import java.util.Optional; -import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executor; import java.util.stream.Collectors; /** @@ -24,11 +24,11 @@ public final class PartyActions { private final QueleaGateway gateway; - private final ExecutorService executor; + private final Executor executor; private final Messages messages; private final Logger logger; - public PartyActions(QueleaGateway gateway, ExecutorService executor, Messages messages, Logger logger) { + public PartyActions(QueleaGateway gateway, Executor executor, Messages messages, Logger logger) { this.gateway = gateway; this.executor = executor; this.messages = messages; @@ -54,7 +54,7 @@ public void invite(LocalPlayer player, String name) { */ public void accept(LocalPlayer player, String inviter) { run(player, locale -> { - Optional invite = chooseInvite(player, locale, inviter); + Optional invite = chooseInvite(player, locale, inviter, true); if (invite.isEmpty()) { return; } @@ -68,7 +68,7 @@ public void accept(LocalPlayer player, String inviter) { */ public void deny(LocalPlayer player, String inviter) { run(player, locale -> { - Optional invite = chooseInvite(player, locale, inviter); + Optional invite = chooseInvite(player, locale, inviter, false); if (invite.isEmpty()) { return; } @@ -139,27 +139,33 @@ public void chat(LocalPlayer player, String message) { gateway.chat(party.id(), player.id().toString(), message)); } - private Optional chooseInvite(LocalPlayer player, Locale locale, String inviter) { - List invites = gateway.invites(player.id().toString()); - if (invites.isEmpty()) { - player.send(messages.message(locale, "command.invites.none")); - return Optional.empty(); - } - if (inviter == null) { - if (invites.size() == 1) { - return Optional.of(invites.get(0)); - } + /** + * The invite a command refers to. Sends the answer and returns empty when there is none to pick. An accept with no + * matching invite from a player who is already in a party answers that they are already in it: that is what a + * second click on an old [Accept] button means. + */ + private Optional chooseInvite(LocalPlayer player, Locale locale, String inviter, boolean accepting) { + String playerId = player.id().toString(); + List invites = gateway.invites(playerId); + if (inviter == null && invites.size() > 1) { player.send(messages.message(locale, "command.invites.ambiguous", Placeholder.unparsed("names", invites.stream().map(Invite::inviterName).collect(Collectors.joining(", "))))); return Optional.empty(); } Optional match = invites.stream() - .filter(invite -> invite.inviterName().equalsIgnoreCase(inviter)) + .filter(invite -> inviter == null || invite.inviterName().equalsIgnoreCase(inviter)) .findFirst(); - if (match.isEmpty()) { + if (match.isPresent()) { + return match; + } + if (accepting && gateway.partyOf(playerId).isPresent()) { + player.send(messages.message(locale, "command.accept.already-member")); + } else if (invites.isEmpty()) { + player.send(messages.message(locale, "command.invites.none")); + } else { player.send(messages.message(locale, "command.invites.unknown", Placeholder.unparsed("name", inviter))); } - return match; + return Optional.empty(); } private void withParty(LocalPlayer player, PartyAction action) { diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyCommands.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyCommands.java index 7b22d65..9564a01 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyCommands.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyCommands.java @@ -125,18 +125,6 @@ private int playersOnly(CommandSource source, Consumer action) { private net.kyori.adventure.text.Component help(CommandSource source) { Locale locale = source instanceof Player player ? player.getEffectiveLocale() : Locale.ENGLISH; - return messages.message(locale, "command.help.title") - .append(net.kyori.adventure.text.Component.newline()) - .append(messages.line(locale, "command.help.create")).append(net.kyori.adventure.text.Component.newline()) - .append(messages.line(locale, "command.help.invite")).append(net.kyori.adventure.text.Component.newline()) - .append(messages.line(locale, "command.help.accept")).append(net.kyori.adventure.text.Component.newline()) - .append(messages.line(locale, "command.help.deny")).append(net.kyori.adventure.text.Component.newline()) - .append(messages.line(locale, "command.help.leave")).append(net.kyori.adventure.text.Component.newline()) - .append(messages.line(locale, "command.help.list")).append(net.kyori.adventure.text.Component.newline()) - .append(messages.line(locale, "command.help.kick")).append(net.kyori.adventure.text.Component.newline()) - .append(messages.line(locale, "command.help.promote")).append(net.kyori.adventure.text.Component.newline()) - .append(messages.line(locale, "command.help.disband")).append(net.kyori.adventure.text.Component.newline()) - .append(messages.line(locale, "command.help.warp")).append(net.kyori.adventure.text.Component.newline()) - .append(messages.line(locale, "command.help.chat")); + return HelpText.of(messages, locale); } } diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/event/EventHandler.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/event/EventHandler.java index 85a6647..bcb39cb 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/event/EventHandler.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/event/EventHandler.java @@ -2,6 +2,7 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import net.onelitefeather.quelea.velocity.follow.FollowPolicy; @@ -28,6 +29,7 @@ public final class EventHandler { private static final String EXPIRED_ALONE = "expired-alone"; + private static final String DISBANDED_BY_LEADER = "leader"; private final LocalPlayers players; private final Messages messages; @@ -43,12 +45,14 @@ public EventHandler(LocalPlayers players, Messages messages, FollowPolicy follow public void handle(PartyEvent event) { switch (event.type()) { - case "party.created" -> notifyMembers(event, null, "party.created", Placeholder.unparsed("player", leaderName(event))); + case "party.created" -> notifyMembers(event, event.leaderId(), null, "party.created", + Placeholder.unparsed("player", leaderName(event))); case "party.invited" -> invited(event); - case "party.joined" -> notifyMembers(event, event.field("playerId"), "party.joined", playerName(event)); - case "party.denied" -> notifyMembers(event, event.field("playerId"), "party.denied", playerName(event)); - case "party.left" -> notifyMembers(event, event.field("playerId"), "party.left", playerName(event)); - case "party.kicked" -> notifyMembers(event, event.field("playerId"), "party.kicked", playerName(event)); + case "party.joined" -> notifyOthers(event, "party.joined", playerName(event)); + case "party.denied" -> notifyOthers(event, "party.denied", playerName(event)); + case "party.left" -> notifyOthers(event, "party.left", playerName(event)); + case "party.kicked" -> notifyMembers(event, event.leaderId(), event.field("playerId"), "party.kicked", + playerName(event)); case "party.leader-changed" -> leaderChanged(event); case "party.disbanded" -> disbanded(event); case "party.expiring" -> expiring(event); @@ -67,10 +71,15 @@ private void invited(PartyEvent event) { return; } players.find(target).ifPresent(player -> { + TagResolver inviter = Placeholder.unparsed("inviter", inviterName); Component accept = messages.line(player.locale(), "party.invite.accept") - .clickEvent(ClickEvent.runCommand("/party accept " + inviterName)); + .clickEvent(ClickEvent.runCommand("/party accept " + inviterName)) + .hoverEvent(HoverEvent.showText( + messages.line(player.locale(), "party.invite.accept.hover", inviter))); Component deny = messages.line(player.locale(), "party.invite.deny") - .clickEvent(ClickEvent.runCommand("/party deny " + inviterName)); + .clickEvent(ClickEvent.runCommand("/party deny " + inviterName)) + .hoverEvent(HoverEvent.showText( + messages.line(player.locale(), "party.invite.deny.hover", inviter))); player.send(messages.message(player.locale(), "party.invited", Placeholder.unparsed("inviter", inviterName)) .append(Component.space()) @@ -82,26 +91,37 @@ private void invited(PartyEvent event) { /** * Sends {@code key} to the members of the event, and {@code key.self} to the affected player (who may not be a - * member any more). The affected player never gets both. + * member any more). The actor of the command never gets either: their command answer already says it. + * + * @param actorId the player who ran the command, or {@code null} when the event does not name one + * @param affectedId the player the change is about, or {@code null} */ - private void notifyMembers(PartyEvent event, String affectedId, String key, TagResolver... resolvers) { - if (affectedId != null) { + private void notifyMembers(PartyEvent event, String actorId, String affectedId, String key, + TagResolver... resolvers) { + if (affectedId != null && !affectedId.equals(actorId)) { sendTo(affectedId, key + ".self", resolvers); } for (Member member : event.members()) { - if (!member.id().equals(affectedId)) { + if (!member.id().equals(affectedId) && !member.id().equals(actorId)) { sendTo(member.id(), key, resolvers); } } } + /** For events where the actor is the affected player: everyone else in the party hears about it. */ + private void notifyOthers(PartyEvent event, String key, TagResolver... resolvers) { + String actorId = event.field("playerId"); + notifyMembers(event, actorId, actorId, key, resolvers); + } + private void leaderChanged(PartyEvent event) { String leaderId = event.leaderId(); - if (leaderId != null) { + String actorId = event.field("previousLeaderId"); + if (leaderId != null && !leaderId.equals(actorId)) { sendTo(leaderId, "party.leader-changed.self"); } for (Member member : event.members()) { - if (!member.id().equals(leaderId)) { + if (!member.id().equals(leaderId) && !member.id().equals(actorId)) { sendTo(member.id(), "party.leader-changed", Placeholder.unparsed("player", leaderName(event))); } } @@ -112,9 +132,14 @@ private void disbanded(PartyEvent event) { return; } // Unknown reasons keep the generic message, so the service can add reasons without a plugin release. - String key = EXPIRED_ALONE.equals(event.field("reason")) ? "party.disbanded.expired-alone" : "party.disbanded"; + String reason = event.field("reason"); + String key = EXPIRED_ALONE.equals(reason) ? "party.disbanded.expired-alone" : "party.disbanded"; + // A leader who disbanded the party already read the command's answer. + String actor = DISBANDED_BY_LEADER.equals(reason) ? event.leaderId() : null; for (PlayerRef former : QueleaJson.players(event.payload().getAsJsonArray("formerMembers"))) { - sendTo(former.id(), key); + if (!former.id().equals(actor)) { + sendTo(former.id(), key); + } } } diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/i18n/Messages.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/i18n/Messages.java index 9b76f41..ca04656 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/i18n/Messages.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/i18n/Messages.java @@ -5,61 +5,78 @@ import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import net.onelitefeather.quelea.velocity.http.QueleaProblemException; +import org.slf4j.Logger; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.io.Reader; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Properties; -import java.util.Set; +import java.util.stream.Stream; /** - * Renders the plugin's messages. German for players whose client locale is German, English otherwise. Templates are - * MiniMessage strings from {@code lang/messages_en.properties} and {@code lang/messages_de.properties}. + * Renders the plugin's messages. The bundle for a player is looked up as {@code language_COUNTRY}, then + * {@code language}, then English. Built-in bundles come from the classpath ({@code en}, {@code de}); every + * {@code messages_.properties} in the override directory is merged key by key over the bundle of the same tag, + * or forms a new language. A key missing from the bundles falls back to the built-in English text. Templates are + * MiniMessage strings; an override template that does not parse is logged once and replaced by the English text. */ public final class Messages { private static final String PROBLEM_PREFIX = "problem."; private static final String UNKNOWN_PROBLEM = "problem.unknown"; - - private final Properties english; - private final Properties german; + private static final String ENGLISH = "en"; + private static final List BUILT_IN = List.of(ENGLISH, "de"); + private static final String FILE_PREFIX = "messages_"; + private static final String FILE_SUFFIX = ".properties"; + + /** Merged bundles by normalised tag ({@code en}, {@code de}, {@code fr}, {@code de_AT}). */ + private final Map bundles; + /** The built-in English bundle, the last resort for a key that an override broke. */ + private final Properties builtInEnglish; private final MiniMessage miniMessage = MiniMessage.miniMessage(); - private Messages(Properties english, Properties german) { - this.english = english; - this.german = german; + private Messages(Map bundles, Properties builtInEnglish) { + this.bundles = bundles; + this.builtInEnglish = builtInEnglish; } /** - * @return the messages read from the plugin's classpath bundles + * Reads the built-in bundles from the classpath and merges the overrides from {@code overrideDirectory}. + * + * @param overrideDirectory the directory with {@code messages_.properties} files; a missing directory means + * no overrides, {@code null} too + * @param logger receives one warning per override template that does not parse + * @return the messages, read once */ - public static Messages load() { - return new Messages(read("en"), read("de")); - } - - private static Properties read(String language) { - String path = "/lang/messages_" + language + ".properties"; - try (InputStream in = Messages.class.getResourceAsStream(path)) { - if (in == null) { - throw new IllegalStateException("missing message bundle " + path); + public static Messages load(Path overrideDirectory, Logger logger) { + Properties builtInEnglish = readBuiltIn(ENGLISH); + Map bundles = new HashMap<>(); + bundles.put(ENGLISH, copy(builtInEnglish)); + for (String language : BUILT_IN) { + if (!ENGLISH.equals(language)) { + bundles.put(language, readBuiltIn(language)); } - Properties properties = new Properties(); - properties.load(new InputStreamReader(in, StandardCharsets.UTF_8)); - return properties; - } catch (IOException e) { - throw new UncheckedIOException(e); } - } - - /** - * @param locale the player's locale - * @return {@code true} for German, {@code false} for English - */ - public static boolean isGerman(Locale locale) { - return locale != null && "de".equals(locale.getLanguage()); + if (overrideDirectory != null && Files.isDirectory(overrideDirectory)) { + for (Path file : overrideFiles(overrideDirectory)) { + String tag = tagOf(file.getFileName().toString()); + Properties override = readFile(file, logger); + if (tag != null && override != null) { + merge(bundles, tag, override, builtInEnglish, logger); + } + } + } + return new Messages(bundles, builtInEnglish); } /** @@ -97,31 +114,136 @@ public Component unavailable(Locale locale) { } /** - * @return every message key of the bundle used for {@code locale} + * @return whether {@code key} has a template for {@code locale}, directly or through the English fallback */ - public Set keys(Locale locale) { - return bundle(locale).stringPropertyNames(); + public boolean hasKey(Locale locale, String key) { + return lookup(locale, key) != null; + } + + private String template(Locale locale, String key) { + String template = lookup(locale, key); + if (template == null) { + throw new IllegalArgumentException("no message for key " + key); + } + return template; } /** - * @return whether the bundle for {@code locale} defines {@code key} + * The first template for {@code key} along the chain {@code language_COUNTRY}, {@code language}, English, or + * {@code null}. */ - public boolean hasKey(Locale locale, String key) { - return bundle(locale).containsKey(key); + private String lookup(Locale locale, String key) { + for (Properties bundle : chain(locale)) { + String template = bundle.getProperty(key); + if (template != null) { + return template; + } + } + return null; } - private Properties bundle(Locale locale) { - return isGerman(locale) ? german : english; + private List chain(Locale locale) { + List chain = new ArrayList<>(4); + if (locale != null) { + String language = locale.getLanguage(); + String country = locale.getCountry(); + if (!country.isEmpty() && bundles.containsKey(language + "_" + country)) { + chain.add(bundles.get(language + "_" + country)); + } + if (bundles.containsKey(language)) { + chain.add(bundles.get(language)); + } + } + chain.add(bundles.get(ENGLISH)); + chain.add(builtInEnglish); + return chain; } - private String template(Locale locale, String key) { - String template = bundle(locale).getProperty(key); - if (template == null) { - template = english.getProperty(key); + private static void merge(Map bundles, String tag, Properties override, + Properties builtInEnglish, Logger logger) { + Properties target = bundles.computeIfAbsent(tag, ignored -> new Properties()); + for (String key : override.stringPropertyNames()) { + String template = override.getProperty(key); + if (parses(template)) { + target.setProperty(key, template); + } else { + // Dropping the key lets the lookup fall through to the English text. + target.remove(key); + logger.warn("Message {} for language {} is not valid MiniMessage; the English text is used", + key, tag); + } } - if (template == null) { - throw new IllegalArgumentException("no message for key " + key); + } + + private static boolean parses(String template) { + try { + MiniMessage.miniMessage().deserialize(template); + return true; + } catch (RuntimeException e) { + return false; } - return template; + } + + private static List overrideFiles(Path directory) { + try (Stream files = Files.list(directory)) { + return files.filter(path -> tagOf(path.getFileName().toString()) != null) + .sorted() + .toList(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** + * @return the normalised tag of {@code messages_.properties} ({@code fr} or {@code de_AT}), or {@code null} + * for any other file name + */ + static String tagOf(String fileName) { + if (!fileName.startsWith(FILE_PREFIX) || !fileName.endsWith(FILE_SUFFIX)) { + return null; + } + String raw = fileName.substring(FILE_PREFIX.length(), fileName.length() - FILE_SUFFIX.length()); + String[] parts = raw.split("_", -1); + if (parts.length > 2 || parts[0].isEmpty() || !parts[0].chars().allMatch(Character::isLetter)) { + return null; + } + if (parts.length == 1) { + return parts[0].toLowerCase(Locale.ROOT); + } + if (parts[1].length() != 2 || !parts[1].chars().allMatch(Character::isLetter)) { + return null; + } + return parts[0].toLowerCase(Locale.ROOT) + "_" + parts[1].toUpperCase(Locale.ROOT); + } + + private static Properties readBuiltIn(String language) { + String path = "/lang/" + FILE_PREFIX + language + FILE_SUFFIX; + try (InputStream in = Messages.class.getResourceAsStream(path)) { + if (in == null) { + throw new IllegalStateException("missing message bundle " + path); + } + Properties properties = new Properties(); + properties.load(new InputStreamReader(in, StandardCharsets.UTF_8)); + return properties; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static Properties readFile(Path file, Logger logger) { + Properties properties = new Properties(); + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + properties.load(reader); + return properties; + } catch (IOException e) { + logger.warn("Message file {} could not be read and is ignored: {}", file, e.getMessage()); + return null; + } + } + + private static Properties copy(Properties source) { + Properties copy = new Properties(); + copy.putAll(source); + return copy; } } diff --git a/velocity/src/main/resources/lang/messages_de.properties b/velocity/src/main/resources/lang/messages_de.properties index a855379..ae1c257 100644 --- a/velocity/src/main/resources/lang/messages_de.properties +++ b/velocity/src/main/resources/lang/messages_de.properties @@ -1,78 +1,102 @@ -# MiniMessage-Vorlagen. -Platzhalter werden mit reinem (nicht interpretiertem) Text gefüllt. +# MiniMessage-Vorlagen. -Platzhalter werden mit reinem (nicht interpretiertem) Text gefüllt, Spielereingaben werden nie formatiert. +# +# Farbleitfaden (die Bedeutung jeder Farbe beim Übersetzen beibehalten): +# Markenverlauf Präfix und Überschriften +# Befehle <#c4b5fd> /party invite, /pc +# Argumente <#a78bfa> [Spieler] +# Erklärungen +# Trenner » • │ - +# Erfolg <#86efac> +# Fehler <#fca5a5> alle problem.*-Schlüssel +# Hinweise <#fde68a> +# Leiter ★ +# Namen prefix=Party » -problem.validation-failed=Die Anfrage war ungültig. Prüfe den Befehl und versuche es erneut. -problem.not-found=Der Party-Dienst kennt diese Anfrage nicht. -problem.method-not-allowed=Diese Aktion ist hier nicht verfügbar. -problem.unsupported-media-type=Der Party-Dienst hat die Anfrage nicht verstanden. -problem.unauthorized=Der Proxy konnte sich nicht beim Party-Dienst anmelden. Das Team sollte seine Konfiguration prüfen. -problem.forbidden=Der Proxy konnte sich nicht beim Party-Dienst anmelden. Das Team sollte seine Konfiguration prüfen. -problem.precondition-failed=Die Party hat sich inzwischen geändert. Bitte versuche es erneut. -problem.idempotency-key-reused=Diese Anfrage wird bereits verarbeitet. Bitte versuche es in einem Moment erneut. -problem.request-in-progress=Diese Anfrage wird bereits verarbeitet. Bitte versuche es in einem Moment erneut. -problem.party-not-found=Diese Party existiert nicht mehr. -problem.not-in-party=Du bist in keiner Party. -problem.already-in-party=Du bist bereits in einer Party. Verlasse sie zuerst mit /party leave. -problem.not-leader=Nur der Party-Leiter kann das tun. -problem.not-member=Du bist nicht Mitglied dieser Party. -problem.party-full=Die Party ist voll. -problem.player-not-online=Dieser Spieler ist nicht online. -problem.invite-not-found=Du hast keine gültige Einladung von dieser Party. Bitte den Leiter, dich erneut einzuladen. -problem.cannot-target-self=Das kannst du nicht bei dir selbst tun. -problem.lock-timeout=Die Party ist gerade beschäftigt. Bitte versuche es erneut. -problem.internal-error=Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut. -problem.unknown=Etwas ist schiefgelaufen (). Bitte versuche es erneut. -problem.unavailable=Der Party-Dienst ist gerade nicht erreichbar, bitte versuche es später erneut. +problem.validation-failed=<#fca5a5>Die Anfrage war ungültig. Prüfe den Befehl und versuche es erneut. +problem.not-found=<#fca5a5>Der Party-Dienst kennt diese Anfrage nicht. +problem.method-not-allowed=<#fca5a5>Diese Aktion ist hier nicht verfügbar. +problem.unsupported-media-type=<#fca5a5>Der Party-Dienst hat die Anfrage nicht verstanden. +problem.unauthorized=<#fca5a5>Der Proxy konnte sich nicht beim Party-Dienst anmelden. Das Team sollte seine Konfiguration prüfen. +problem.forbidden=<#fca5a5>Der Proxy konnte sich nicht beim Party-Dienst anmelden. Das Team sollte seine Konfiguration prüfen. +problem.precondition-failed=<#fca5a5>Die Party hat sich inzwischen geändert. Bitte versuche es erneut. +problem.idempotency-key-reused=<#fca5a5>Diese Anfrage wird bereits verarbeitet. Bitte versuche es in einem Moment erneut. +problem.request-in-progress=<#fca5a5>Diese Anfrage wird bereits verarbeitet. Bitte versuche es in einem Moment erneut. +problem.party-not-found=<#fca5a5>Diese Party existiert nicht mehr. +problem.not-in-party=<#fca5a5>Du bist in keiner Party. +problem.already-in-party=<#fca5a5>Du bist bereits in einer Party. Verlasse sie zuerst mit <#c4b5fd>/party leave. +problem.not-leader=<#fca5a5>Nur der Party-Leiter kann das tun. +problem.not-member=<#fca5a5>Du bist nicht Mitglied dieser Party. +problem.party-full=<#fca5a5>Die Party ist voll. +problem.player-not-online=<#fca5a5>Dieser Spieler ist nicht online. +problem.invite-not-found=<#fca5a5>Du hast keine gültige Einladung von dieser Party. Bitte den Leiter, dich erneut einzuladen. +problem.cannot-target-self=<#fca5a5>Das kannst du nicht bei dir selbst tun. +problem.lock-timeout=<#fca5a5>Die Party ist gerade beschäftigt. Bitte versuche es erneut. +problem.internal-error=<#fca5a5>Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut. +problem.unknown=<#fca5a5>Etwas ist schiefgelaufen (). Bitte versuche es erneut. +problem.unavailable=<#fca5a5>Der Party-Dienst ist gerade nicht erreichbar, bitte versuche es später erneut. -command.players-only=Nur Spieler können diesen Befehl benutzen. +command.players-only=<#fca5a5>Nur Spieler können diesen Befehl benutzen. command.help.title=Party-Befehle -command.help.create=/party create - eine Party gründen -command.help.invite=/party invite [player] - einen Spieler einladen -command.help.accept=/party accept [player] - eine Einladung annehmen -command.help.deny=/party deny [player] - eine Einladung ablehnen -command.help.leave=/party leave - die Party verlassen -command.help.list=/party list - die Mitglieder anzeigen -command.help.kick=/party kick [player] - ein Mitglied entfernen (Leiter) -command.help.promote=/party promote [player] - ein Mitglied zum Leiter machen (Leiter) -command.help.disband=/party disband - die Party auflösen (Leiter) -command.help.warp=/party warp - die Party zu deinem Server holen (Leiter) -command.help.chat=/pc [message] - mit deiner Party chatten -command.created=Party erstellt. Lade Spieler mit /party invite [player] ein. -command.invite.sent=Einladung an gesendet. -command.invites.none=Du hast keine offenen Party-Einladungen. -command.invites.unknown=Du hast keine Einladung von . -command.invites.ambiguous=Du hast Einladungen von . Nenne eine davon, z. B. mit /party accept [player]. -command.accept.done=Du bist der Party beigetreten. -command.deny.done=Du hast die Einladung abgelehnt. -command.leave.done=Du hast die Party verlassen. -command.kick.done= wurde aus der Party entfernt. -command.promote.done= ist jetzt der Party-Leiter. -command.disband.done=Die Party wurde aufgelöst. -command.no-party=Du bist in keiner Party. -command.no-member= ist nicht in deiner Party. -command.list.header=Party-Mitglieder (): -command.list.leader= (Leiter) -command.list.member= -command.warp.done=Die Party wird zu geholt. -command.warp.no-server=Du bist auf keinem Server. +command.help.separator=──────────────────────── +command.help.create=<#c4b5fd>/party create » eine Party gründen +command.help.invite=<#c4b5fd>/party invite <#a78bfa>[Spieler] » einen Spieler einladen +command.help.accept=<#c4b5fd>/party accept <#a78bfa>[Spieler] » eine Einladung annehmen +command.help.deny=<#c4b5fd>/party deny <#a78bfa>[Spieler] » eine Einladung ablehnen +command.help.leave=<#c4b5fd>/party leave » die Party verlassen +command.help.list=<#c4b5fd>/party list » die Mitglieder anzeigen +command.help.kick=<#c4b5fd>/party kick <#a78bfa>[Spieler] » ein Mitglied entfernen (Leiter) +command.help.promote=<#c4b5fd>/party promote <#a78bfa>[Spieler] » ein Mitglied zum Leiter machen (Leiter) +command.help.disband=<#c4b5fd>/party disband » die Party auflösen (Leiter) +command.help.warp=<#c4b5fd>/party warp » die Party zu deinem Server holen (Leiter) +command.help.chat=<#c4b5fd>/pc <#a78bfa>[Nachricht] » mit deiner Party chatten +command.help.create.hover=Gründe eine Party, in der du der Leiter bist. +command.help.invite.hover=Lade einen online Spieler ein. Er bekommt die Knöpfe [Annehmen] und [Ablehnen]. +command.help.accept.hover=Tritt der Party bei, die dich eingeladen hat. +command.help.deny.hover=Lehne eine Einladung ab. +command.help.leave.hover=Verlasse deine Party. +command.help.list.hover=Zeigt die Mitglieder. Der Leiter ist mit ★ markiert. +command.help.kick.hover=Entfernt ein Mitglied aus deiner Party. Nur für Leiter. +command.help.promote.hover=Macht ein Mitglied zum neuen Leiter. Nur für Leiter. +command.help.disband.hover=Beendet die Party für alle. Nur für Leiter. +command.help.warp.hover=Holt alle Mitglieder zu deinem Server. Nur für Leiter. +command.help.chat.hover=Sendet eine Nachricht, die nur deine Party liest. +command.created=<#86efac>Party erstellt. Lade Spieler mit <#c4b5fd>/party invite <#a78bfa>[Spieler] ein. +command.invite.sent=<#86efac>Einladung an gesendet. +command.invites.none=<#fca5a5>Du hast keine offenen Party-Einladungen. +command.invites.unknown=<#fca5a5>Du hast keine Einladung von . +command.invites.ambiguous=<#fde68a>Du hast Einladungen von . Nenne eine davon, z. B. mit <#c4b5fd>/party accept <#a78bfa>[Spieler]. +command.accept.done=<#86efac>Du bist der Party beigetreten. +command.accept.already-member=<#fde68a>Du bist bereits in dieser Party. +command.deny.done=<#86efac>Du hast die Einladung abgelehnt. +command.leave.done=<#86efac>Du hast die Party verlassen. +command.kick.done=<#86efac> wurde aus der Party entfernt. +command.promote.done=<#86efac> ist jetzt der Party-Leiter. +command.disband.done=<#86efac>Die Party wurde aufgelöst. +command.no-party=<#fca5a5>Du bist in keiner Party. +command.no-member=<#fca5a5> ist nicht in deiner Party. +command.list.header=Party-Mitglieder (): +command.list.leader= ★ +command.list.member= • +command.warp.done=<#86efac>Die Party wird zu geholt. +command.warp.no-server=<#fca5a5>Du bist auf keinem Server. -party.created= hat die Party erstellt. -party.joined= ist der Party beigetreten. -party.joined.self=Du bist der Party beigetreten. -party.denied= hat die Einladung abgelehnt. -party.denied.self=Du hast die Einladung abgelehnt. -party.left= hat die Party verlassen. -party.left.self=Du hast die Party verlassen. -party.kicked= wurde aus der Party entfernt. -party.kicked.self=Du wurdest aus der Party entfernt. -party.leader-changed= ist jetzt der Party-Leiter. -party.leader-changed.self=Du bist jetzt der Party-Leiter. -party.disbanded=Die Party wurde aufgelöst. -party.disbanded.expired-alone=Die Party wurde geschlossen, weil niemand ihr beigetreten ist. -party.expiring=Deine Party schließt in Minuten, weil niemand beigetreten ist. Lade jemanden mit /party invite [Spieler] ein. -party.invited= lädt dich zur Party ein. -party.invite.accept=[Annehmen] -party.invite.deny=[Ablehnen] -party.warp.unknown-server=Die Party kann nicht zu folgen: Der Proxy kennt diesen Server nicht. -party.warp.refused=Die Party konnte nicht zu folgen. Du bleibst, wo du bist. -party.chat=[Party] : +party.created= <#86efac>hat die Party erstellt. +party.joined= <#86efac>ist der Party beigetreten. +party.denied= hat die Einladung abgelehnt. +party.left= hat die Party verlassen. +party.kicked= <#fca5a5>wurde aus der Party entfernt. +party.kicked.self=<#fca5a5>Du wurdest aus der Party entfernt. +party.leader-changed=★ ist jetzt der Party-Leiter. +party.leader-changed.self=★ Du bist jetzt der Party-Leiter. +party.disbanded=<#fca5a5>Die Party wurde aufgelöst. +party.disbanded.expired-alone=<#fca5a5>Die Party wurde geschlossen, weil niemand ihr beigetreten ist. +party.expiring=<#fde68a>Deine Party schließt in Minuten, weil niemand beigetreten ist. Lade jemanden ein mit <#c4b5fd>/party invite <#a78bfa>[Spieler] +party.invited= lädt dich zur Party ein. +party.invite.accept=<#86efac>[Annehmen] +party.invite.accept.hover=Der Party von beitreten. +party.invite.deny=<#fca5a5>[Ablehnen] +party.invite.deny.hover=Die Einladung von ablehnen. +party.warp.unknown-server=<#fca5a5>Die Party kann nicht zu folgen: Der Proxy kennt diesen Server nicht. +party.warp.refused=<#fca5a5>Die Party konnte nicht zu folgen. Du bleibst, wo du bist. +party.chat=Party │ <#e9d5ff> » diff --git a/velocity/src/main/resources/lang/messages_en.properties b/velocity/src/main/resources/lang/messages_en.properties index 6964971..bafc242 100644 --- a/velocity/src/main/resources/lang/messages_en.properties +++ b/velocity/src/main/resources/lang/messages_en.properties @@ -1,78 +1,102 @@ -# MiniMessage templates. placeholders are filled with plain (unparsed) text. +# MiniMessage templates. placeholders are filled with plain (unparsed) text, so player input is never formatted. +# +# Colour guide (keep the meaning of each colour when you translate or restyle a message): +# brand gradient prefix and headers +# commands <#c4b5fd> /party invite, /pc +# arguments <#a78bfa> [player] +# explanations +# separators » • │ - +# success <#86efac> +# errors <#fca5a5> every problem.* key +# hints <#fde68a> +# leader ★ +# names prefix=Party » -problem.validation-failed=That request was not valid. Check the command and try again. -problem.not-found=The party service does not know that request. -problem.method-not-allowed=That action is not available here. -problem.unsupported-media-type=The party service did not understand the request. -problem.unauthorized=The proxy could not sign in to the party service. Staff should check its configuration. -problem.forbidden=The proxy could not sign in to the party service. Staff should check its configuration. -problem.precondition-failed=The party changed meanwhile. Please try again. -problem.idempotency-key-reused=That request is already being processed. Please try again in a moment. -problem.request-in-progress=That request is already being processed. Please try again in a moment. -problem.party-not-found=That party no longer exists. -problem.not-in-party=You are not in a party. -problem.already-in-party=You are already in a party. Leave it first with /party leave. -problem.not-leader=Only the party leader can do that. -problem.not-member=You are not a member of that party. -problem.party-full=The party is full. -problem.player-not-online=That player is not online. -problem.invite-not-found=You have no current invite from that party. Ask the leader to invite you again. -problem.cannot-target-self=You cannot do that to yourself. -problem.lock-timeout=The party is busy right now. Please try again. -problem.internal-error=An unexpected error occurred. Please try again. -problem.unknown=Something went wrong (). Please try again. -problem.unavailable=The party service is unavailable, please try again later. +problem.validation-failed=<#fca5a5>That request was not valid. Check the command and try again. +problem.not-found=<#fca5a5>The party service does not know that request. +problem.method-not-allowed=<#fca5a5>That action is not available here. +problem.unsupported-media-type=<#fca5a5>The party service did not understand the request. +problem.unauthorized=<#fca5a5>The proxy could not sign in to the party service. Staff should check its configuration. +problem.forbidden=<#fca5a5>The proxy could not sign in to the party service. Staff should check its configuration. +problem.precondition-failed=<#fca5a5>The party changed meanwhile. Please try again. +problem.idempotency-key-reused=<#fca5a5>That request is already being processed. Please try again in a moment. +problem.request-in-progress=<#fca5a5>That request is already being processed. Please try again in a moment. +problem.party-not-found=<#fca5a5>That party no longer exists. +problem.not-in-party=<#fca5a5>You are not in a party. +problem.already-in-party=<#fca5a5>You are already in a party. Leave it first with <#c4b5fd>/party leave. +problem.not-leader=<#fca5a5>Only the party leader can do that. +problem.not-member=<#fca5a5>You are not a member of that party. +problem.party-full=<#fca5a5>The party is full. +problem.player-not-online=<#fca5a5>That player is not online. +problem.invite-not-found=<#fca5a5>You have no current invite from that party. Ask the leader to invite you again. +problem.cannot-target-self=<#fca5a5>You cannot do that to yourself. +problem.lock-timeout=<#fca5a5>The party is busy right now. Please try again. +problem.internal-error=<#fca5a5>An unexpected error occurred. Please try again. +problem.unknown=<#fca5a5>Something went wrong (). Please try again. +problem.unavailable=<#fca5a5>The party service is unavailable, please try again later. -command.players-only=Only players can use this command. +command.players-only=<#fca5a5>Only players can use this command. command.help.title=Party commands -command.help.create=/party create - start a party -command.help.invite=/party invite [player] - invite a player -command.help.accept=/party accept [player] - accept an invite -command.help.deny=/party deny [player] - decline an invite -command.help.leave=/party leave - leave your party -command.help.list=/party list - show the members -command.help.kick=/party kick [player] - remove a member (leader) -command.help.promote=/party promote [player] - make a member the leader (leader) -command.help.disband=/party disband - end the party (leader) -command.help.warp=/party warp - bring the party to your server (leader) -command.help.chat=/pc [message] - chat with your party -command.created=Party created. Invite players with /party invite [player]. -command.invite.sent=Invite sent to . -command.invites.none=You have no pending party invites. -command.invites.unknown=You have no invite from . -command.invites.ambiguous=You have invites from . Name one of them, for example with /party accept [player]. -command.accept.done=You joined the party. -command.deny.done=You declined the invite. -command.leave.done=You left the party. -command.kick.done= was removed from the party. -command.promote.done= is the new party leader. -command.disband.done=The party was disbanded. -command.no-party=You are not in a party. -command.no-member= is not in your party. -command.list.header=Party members (): -command.list.leader= (leader) -command.list.member= -command.warp.done=Bringing the party to . -command.warp.no-server=You are not on a server. +command.help.separator=──────────────────────── +command.help.create=<#c4b5fd>/party create » start a party +command.help.invite=<#c4b5fd>/party invite <#a78bfa>[player] » invite a player +command.help.accept=<#c4b5fd>/party accept <#a78bfa>[player] » accept an invite +command.help.deny=<#c4b5fd>/party deny <#a78bfa>[player] » decline an invite +command.help.leave=<#c4b5fd>/party leave » leave your party +command.help.list=<#c4b5fd>/party list » show the members +command.help.kick=<#c4b5fd>/party kick <#a78bfa>[player] » remove a member (leader) +command.help.promote=<#c4b5fd>/party promote <#a78bfa>[player] » make a member the leader (leader) +command.help.disband=<#c4b5fd>/party disband » end the party (leader) +command.help.warp=<#c4b5fd>/party warp » bring the party to your server (leader) +command.help.chat=<#c4b5fd>/pc <#a78bfa>[message] » chat with your party +command.help.create.hover=Start a party with you as its leader. +command.help.invite.hover=Invite an online player. They get [Accept] and [Deny] buttons. +command.help.accept.hover=Join the party that invited you. +command.help.deny.hover=Decline an invite. +command.help.leave.hover=Leave your party. +command.help.list.hover=Show the members. The leader is marked with ★. +command.help.kick.hover=Remove a member from your party. Leaders only. +command.help.promote.hover=Make a member the new leader. Leaders only. +command.help.disband.hover=End the party for everyone. Leaders only. +command.help.warp.hover=Bring every member to your server. Leaders only. +command.help.chat.hover=Send a message that only your party reads. +command.created=<#86efac>Party created. Invite players with <#c4b5fd>/party invite <#a78bfa>[player] +command.invite.sent=<#86efac>Invite sent to . +command.invites.none=<#fca5a5>You have no pending party invites. +command.invites.unknown=<#fca5a5>You have no invite from . +command.invites.ambiguous=<#fde68a>You have invites from . Name one of them, for example with <#c4b5fd>/party accept <#a78bfa>[player]. +command.accept.done=<#86efac>You joined the party. +command.accept.already-member=<#fde68a>You are already in this party. +command.deny.done=<#86efac>You declined the invite. +command.leave.done=<#86efac>You left the party. +command.kick.done=<#86efac> was removed from the party. +command.promote.done=<#86efac> is the new party leader. +command.disband.done=<#86efac>The party was disbanded. +command.no-party=<#fca5a5>You are not in a party. +command.no-member=<#fca5a5> is not in your party. +command.list.header=Party members (): +command.list.leader= ★ +command.list.member= • +command.warp.done=<#86efac>Bringing the party to . +command.warp.no-server=<#fca5a5>You are not on a server. -party.created= created the party. -party.joined= joined the party. -party.joined.self=You joined the party. -party.denied= declined the invite. -party.denied.self=You declined the invite. -party.left= left the party. -party.left.self=You left the party. -party.kicked= was kicked from the party. -party.kicked.self=You were kicked from the party. -party.leader-changed= is the new party leader. -party.leader-changed.self=You are the new party leader. -party.disbanded=The party was disbanded. -party.disbanded.expired-alone=The party was closed because nobody joined it. -party.expiring=Your party closes in minutes because nobody joined. Invite someone with /party invite [player]. -party.invited= invites you to their party. -party.invite.accept=[Accept] -party.invite.deny=[Deny] -party.warp.unknown-server=The party cannot follow to : the proxy does not know that server. -party.warp.refused=Could not follow the party to . You stay where you are. -party.chat=[Party] : +party.created= <#86efac>created the party. +party.joined= <#86efac>joined the party. +party.denied= declined the invite. +party.left= left the party. +party.kicked= <#fca5a5>was kicked from the party. +party.kicked.self=<#fca5a5>You were kicked from the party. +party.leader-changed=★ is the new party leader. +party.leader-changed.self=★ You are the new party leader. +party.disbanded=<#fca5a5>The party was disbanded. +party.disbanded.expired-alone=<#fca5a5>The party was closed because nobody joined it. +party.expiring=<#fde68a>Your party closes in minutes because nobody joined. Invite someone with <#c4b5fd>/party invite <#a78bfa>[player] +party.invited= invites you to their party. +party.invite.accept=<#86efac>[Accept] +party.invite.accept.hover=Join the party of . +party.invite.deny=<#fca5a5>[Deny] +party.invite.deny.hover=Decline the invite from . +party.warp.unknown-server=<#fca5a5>The party cannot follow to : the proxy does not know that server. +party.warp.refused=<#fca5a5>Could not follow the party to . You stay where you are. +party.chat=Party │ <#e9d5ff> » diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/FakeQueleaGateway.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/FakeQueleaGateway.java new file mode 100644 index 0000000..ffdf665 --- /dev/null +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/FakeQueleaGateway.java @@ -0,0 +1,113 @@ +package net.onelitefeather.quelea.velocity.command; + +import net.onelitefeather.quelea.velocity.http.Invite; +import net.onelitefeather.quelea.velocity.http.Party; +import net.onelitefeather.quelea.velocity.http.PlayerRef; +import net.onelitefeather.quelea.velocity.http.QueleaGateway; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Answers from fixed state and records the accepted invites. Every other route is unsupported by these tests. + */ +final class FakeQueleaGateway implements QueleaGateway { + + private final List invites = new ArrayList<>(); + private final List parties = new ArrayList<>(); + final List accepted = new ArrayList<>(); + + FakeQueleaGateway withInvite(Invite invite) { + invites.add(invite); + return this; + } + + FakeQueleaGateway withParty(Party party) { + parties.add(party); + return this; + } + + @Override + public Party createParty(String leaderId) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional partyOf(String playerId) { + return parties.stream() + .filter(party -> party.members().stream().anyMatch(member -> member.id().equals(playerId))) + .findFirst(); + } + + @Override + public Invite invite(String actorId, String targetName) { + throw new UnsupportedOperationException(); + } + + @Override + public List invites(String playerId) { + return invites.stream().filter(invite -> invite.targetId().equals(playerId)).toList(); + } + + @Override + public Party acceptInvite(String playerId, String partyId) { + accepted.add(partyId); + return parties.stream().filter(party -> party.id().equals(partyId)).findFirst().orElseThrow(); + } + + @Override + public void denyInvite(String playerId, String partyId) { + throw new UnsupportedOperationException(); + } + + @Override + public void leave(String playerId) { + throw new UnsupportedOperationException(); + } + + @Override + public Party kick(String partyId, String actorId, String targetId) { + throw new UnsupportedOperationException(); + } + + @Override + public Party promote(String partyId, String actorId, String targetId) { + throw new UnsupportedOperationException(); + } + + @Override + public void disband(String partyId, String actorId) { + throw new UnsupportedOperationException(); + } + + @Override + public void warp(String partyId, String actorId, String server) { + throw new UnsupportedOperationException(); + } + + @Override + public void chat(String partyId, String actorId, String message) { + throw new UnsupportedOperationException(); + } + + @Override + public void snapshot(String proxyId, List players) { + throw new UnsupportedOperationException(); + } + + @Override + public void login(String proxyId, String playerId, String name) { + throw new UnsupportedOperationException(); + } + + @Override + public void disconnect(String proxyId, String playerId) { + throw new UnsupportedOperationException(); + } + + @Override + public void heartbeat(String proxyId) { + throw new UnsupportedOperationException(); + } +} diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/HelpTextTest.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/HelpTextTest.java new file mode 100644 index 0000000..7bdbd8b --- /dev/null +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/HelpTextTest.java @@ -0,0 +1,61 @@ +package net.onelitefeather.quelea.velocity.command; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.onelitefeather.quelea.velocity.i18n.Messages; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HelpTextTest { + + @TempDir + Path overrides; + + private static void collect(Component component, List clicks, List> hovers) { + if (component.clickEvent() != null) { + clicks.add(component.clickEvent()); + } + if (component.hoverEvent() != null) { + hovers.add(component.hoverEvent()); + } + for (Component child : component.children()) { + collect(child, clicks, hovers); + } + } + + @Test + void everyHelpLineSuggestsItsCommandOnClick() { + Messages messages = Messages.load(overrides, LoggerFactory.getLogger(HelpTextTest.class)); + List clicks = new ArrayList<>(); + collect(HelpText.of(messages, Locale.ENGLISH), clicks, new ArrayList<>()); + + List suggestions = clicks.stream() + .filter(click -> click.action() == ClickEvent.Action.SUGGEST_COMMAND) + .map(click -> ((ClickEvent.Payload.Text) click.payload()).value()) + .toList(); + assertEquals(HelpText.ENTRIES.size(), suggestions.size(), "one clickable suggestion per help line"); + for (HelpText.Entry entry : HelpText.ENTRIES) { + assertTrue(suggestions.contains(entry.suggestion()), "missing click for " + entry.name()); + } + assertTrue(suggestions.contains("/party invite "), "the invite line suggests the command with a trailing space"); + } + + @Test + void everyHelpLineHasHoverTextInTheLocale() { + Messages messages = Messages.load(overrides, LoggerFactory.getLogger(HelpTextTest.class)); + List> hovers = new ArrayList<>(); + collect(HelpText.of(messages, Locale.GERMAN), new ArrayList<>(), hovers); + + assertEquals(HelpText.ENTRIES.size(), hovers.size(), "one hover per help line"); + } +} diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/PartyActionsTest.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/PartyActionsTest.java new file mode 100644 index 0000000..5c95dbf --- /dev/null +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/PartyActionsTest.java @@ -0,0 +1,84 @@ +package net.onelitefeather.quelea.velocity.command; + +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.onelitefeather.quelea.velocity.http.Invite; +import net.onelitefeather.quelea.velocity.http.Member; +import net.onelitefeather.quelea.velocity.http.Party; +import net.onelitefeather.quelea.velocity.i18n.Messages; +import net.onelitefeather.quelea.velocity.player.FakeLocalPlayer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PartyActionsTest { + + private static final UUID ALICE = UUID.fromString("00000000-0000-0000-0000-00000000000a"); + private static final UUID BOB = UUID.fromString("00000000-0000-0000-0000-00000000000b"); + private static final Instant NOW = Instant.parse("2026-10-28T10:00:00Z"); + private static final String PARTY_ID = "p-1"; + + @TempDir + Path overrides; + + private PartyActions actions(FakeQueleaGateway gateway) { + Messages messages = Messages.load(overrides, LoggerFactory.getLogger(PartyActionsTest.class)); + // Runs the work on the calling thread, so the answer is there when the call returns. + return new PartyActions(gateway, Runnable::run, messages, LoggerFactory.getLogger(PartyActionsTest.class)); + } + + private static Party party() { + return new Party(PARTY_ID, ALICE.toString(), + List.of(new Member(ALICE.toString(), "Alice", NOW), new Member(BOB.toString(), "Bob", NOW)), NOW); + } + + private static Invite invite() { + return new Invite(PARTY_ID, ALICE.toString(), "Alice", BOB.toString(), "Bob", NOW.plusSeconds(60)); + } + + private static String lastAnswer(FakeLocalPlayer player) { + return PlainTextComponentSerializer.plainText().serialize(player.sent().get(player.sent().size() - 1)); + } + + @Test + void acceptAgainFromAPartyThePlayerAlreadyJoinedSaysSo() { + FakeQueleaGateway gateway = new FakeQueleaGateway().withParty(party()); + FakeLocalPlayer bob = new FakeLocalPlayer(BOB, "Bob", Locale.ENGLISH, "Lobby-1"); + + actions(gateway).accept(bob, "Alice"); + + assertEquals(1, bob.sent().size(), "one answer"); + assertTrue(lastAnswer(bob).contains("You are already in this party."), lastAnswer(bob)); + assertTrue(gateway.accepted.isEmpty(), "nothing is joined twice"); + } + + @Test + void acceptWithoutAnyInviteOutsideAPartySaysNoInvitesAreOpen() { + FakeQueleaGateway gateway = new FakeQueleaGateway(); + FakeLocalPlayer bob = new FakeLocalPlayer(BOB, "Bob", Locale.ENGLISH, "Lobby-1"); + + actions(gateway).accept(bob, null); + + assertTrue(lastAnswer(bob).contains("no pending party invites"), lastAnswer(bob)); + } + + @Test + void acceptWithAMatchingInviteJoinsAndAnswersOnce() { + FakeQueleaGateway gateway = new FakeQueleaGateway().withInvite(invite()).withParty(party()); + FakeLocalPlayer bob = new FakeLocalPlayer(BOB, "Bob", Locale.ENGLISH, "Lobby-1"); + + actions(gateway).accept(bob, null); + + assertEquals(List.of(PARTY_ID), gateway.accepted); + assertEquals(1, bob.sent().size(), "one answer"); + assertTrue(lastAnswer(bob).contains("You joined the party."), lastAnswer(bob)); + } +} diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/event/EventHandlerTest.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/event/EventHandlerTest.java index 0538c30..8467312 100644 --- a/velocity/src/test/java/net/onelitefeather/quelea/velocity/event/EventHandlerTest.java +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/event/EventHandlerTest.java @@ -10,7 +10,10 @@ import net.onelitefeather.quelea.velocity.player.FakeLocalPlayers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; +import java.nio.file.Path; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; @@ -31,7 +34,10 @@ class EventHandlerTest { private static final UUID OFFLINE_MEMBER = UUID.fromString("00000000-0000-0000-0000-00000000000d"); private static final Instant NOW = Instant.parse("2026-10-28T10:00:00Z"); - private final Messages messages = Messages.load(); + @TempDir + Path overrides; + + private Messages messages; private final FollowPolicy lobbyMode = FollowPolicy.of("(?i)^lobby.*", FollowMode.LOBBY); private FakeLocalPlayer leader; @@ -41,6 +47,7 @@ class EventHandlerTest { @BeforeEach void setUp() { + messages = Messages.load(overrides, LoggerFactory.getLogger(EventHandlerTest.class)); leader = new FakeLocalPlayer(LEADER, "Alice", Locale.ENGLISH, "Lobby-1"); lobbyFollower = new FakeLocalPlayer(FOLLOWER_IN_LOBBY, "Bob", Locale.ENGLISH, "Lobby-1"); gameFollower = new FakeLocalPlayer(FOLLOWER_IN_GAME, "Carol", Locale.ENGLISH, "Tamias-1"); @@ -137,6 +144,19 @@ void disbandedNotifiesEveryFormerMember() { assertTrue(gameFollower.sent().isEmpty(), "players who were not in the party are not told"); } + @Test + void disbandByTheLeaderDoesNotNotifyTheLeaderAgain() { + PartyEvent disbanded = event("party.disbanded", LEADER.toString(), "", """ + {"formerMembers":[%s,%s],"reason":"leader"}""".formatted( + "{\"id\":\"" + LEADER + "\",\"name\":\"Alice\"}", + "{\"id\":\"" + FOLLOWER_IN_LOBBY + "\",\"name\":\"Bob\"}")); + + handler().handle(disbanded); + + assertTrue(leader.sent().isEmpty(), "the disbanding leader reads only the command answer"); + assertEquals(1, lobbyFollower.sent().size(), "the former member is told"); + } + @Test void disbandedWithOtherReasonKeepsTheGenericMessage() { PartyEvent disbanded = event("party.disbanded", LEADER.toString(), "", """ @@ -192,19 +212,96 @@ void expiringIsNotSentToMembersWhoAreNotTheLeader() { } @Test - void joinedNotifiesMembersAndGivesTheNewMemberTheirOwnMessage() { + void acceptNotifiesTheOtherMembersButNotTheJoiner() { PartyEvent joined = event("party.joined", LEADER.toString(), """ %s,%s""".formatted(member(LEADER, "Alice"), member(FOLLOWER_IN_LOBBY, "Bob")), """ {"playerId":"%s","playerName":"Bob"}""".formatted(FOLLOWER_IN_LOBBY)); handler().handle(joined); - assertEquals(1, lobbyFollower.sent().size(), "the joining player gets exactly one message"); - assertTrue(plain(lobbyFollower.sent().get(0)).contains("You joined"), plain(lobbyFollower.sent().get(0))); + assertTrue(lobbyFollower.sent().isEmpty(), "the joiner reads only the command answer"); assertEquals(1, leader.sent().size(), "the leader is told about the new member"); assertTrue(plain(leader.sent().get(0)).contains("Bob"), plain(leader.sent().get(0))); } + @Test + void createDoesNotNotifyTheLeaderWhoRanTheCommand() { + PartyEvent created = event("party.created", LEADER.toString(), member(LEADER, "Alice"), "{}"); + + handler().handle(created); + + assertTrue(leader.sent().isEmpty(), "the leader reads only the command answer"); + } + + @Test + void kickNotifiesTheKickedPlayerAndTheOtherMembersButNotTheLeader() { + PartyEvent kicked = event("party.kicked", LEADER.toString(), """ + %s,%s""".formatted(member(LEADER, "Alice"), member(FOLLOWER_IN_GAME, "Carol")), """ + {"playerId":"%s","playerName":"Bob"}""".formatted(FOLLOWER_IN_LOBBY)); + + handler().handle(kicked); + + assertTrue(leader.sent().isEmpty(), "the kicking leader reads only the command answer"); + assertEquals(1, lobbyFollower.sent().size(), "the kicked player is told"); + assertEquals(1, gameFollower.sent().size(), "the other member is told"); + } + + @Test + void leaveNotifiesTheRemainingMembersButNotTheLeaver() { + PartyEvent left = event("party.left", LEADER.toString(), member(LEADER, "Alice"), """ + {"playerId":"%s","playerName":"Bob"}""".formatted(FOLLOWER_IN_LOBBY)); + + handler().handle(left); + + assertTrue(lobbyFollower.sent().isEmpty(), "the leaver reads only the command answer"); + assertEquals(1, leader.sent().size(), "the remaining member is told"); + } + + @Test + void promoteNotifiesTheNewLeaderAndOthersButNotThePreviousLeader() { + PartyEvent promoted = event("party.leader-changed", FOLLOWER_IN_LOBBY.toString(), """ + %s,%s,%s""".formatted(member(LEADER, "Alice"), member(FOLLOWER_IN_LOBBY, "Bob"), + member(FOLLOWER_IN_GAME, "Carol")), """ + {"previousLeaderId":"%s","leaderId":"%s"}""".formatted(LEADER, FOLLOWER_IN_LOBBY)); + + handler().handle(promoted); + + assertTrue(leader.sent().isEmpty(), "the previous leader reads only the command answer"); + assertEquals(1, lobbyFollower.sent().size(), "the new leader is told"); + assertTrue(plain(lobbyFollower.sent().get(0)).contains("new party leader"), plain(lobbyFollower.sent().get(0))); + assertEquals(1, gameFollower.sent().size(), "the other member is told"); + } + + @Test + void denyNotifiesTheMembersButNotTheDecliner() { + PartyEvent denied = event("party.denied", LEADER.toString(), member(LEADER, "Alice"), """ + {"playerId":"%s","playerName":"Bob"}""".formatted(FOLLOWER_IN_LOBBY)); + + handler().handle(denied); + + assertTrue(lobbyFollower.sent().isEmpty(), "the decliner reads only the command answer"); + assertEquals(1, leader.sent().size(), "the leader is told"); + } + + @Test + void inviteButtonsExplainThemselvesOnHover() { + PartyEvent invited = event("party.invited", LEADER.toString(), "", """ + {"inviterId":"%s","inviterName":"Alice","targetId":"%s","targetName":"Bob", + "expiresAt":"2026-10-28T10:01:00Z"}""".formatted(LEADER, FOLLOWER_IN_LOBBY)); + + handler().handle(invited); + + assertTrue(hovers(lobbyFollower.sent().get(0)) >= 2, "[Accept] and [Deny] both have hover text"); + } + + private static int hovers(Component component) { + int count = component.hoverEvent() == null ? 0 : 1; + for (Component child : component.children()) { + count += hovers(child); + } + return count; + } + @Test void chatReachesEveryLocalMemberInTheirOwnLanguage() { FakeLocalPlayer german = new FakeLocalPlayer(FOLLOWER_IN_GAME, "Carol", Locale.GERMAN, "Tamias-1"); @@ -216,8 +313,8 @@ void chatReachesEveryLocalMemberInTheirOwnLanguage() { handler().handle(chat); assertEquals(1, leader.sent().size()); - assertEquals("[Party] Alice: gg", plain(leader.sent().get(0))); - assertEquals("[Party] Alice: gg", plain(german.sent().get(0)), "chat lines carry no translated text"); + assertEquals("Party │ Alice » gg", plain(leader.sent().get(0))); + assertEquals("Party │ Alice » gg", plain(german.sent().get(0)), "chat lines carry no translated text"); } @Test diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/i18n/MessagesTest.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/i18n/MessagesTest.java index 6052fa0..b2067ff 100644 --- a/velocity/src/test/java/net/onelitefeather/quelea/velocity/i18n/MessagesTest.java +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/i18n/MessagesTest.java @@ -1,13 +1,23 @@ package net.onelitefeather.quelea.velocity.i18n; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import net.onelitefeather.quelea.velocity.http.QueleaProblemException; import org.junit.jupiter.api.Test; - +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.Locale; +import java.util.Properties; import java.util.Set; import java.util.TreeSet; @@ -24,22 +34,34 @@ class MessagesTest { "already-in-party", "not-leader", "not-member", "party-full", "player-not-online", "invite-not-found", "cannot-target-self", "lock-timeout", "internal-error"); - private final Messages messages = Messages.load(); + private static final org.slf4j.Logger LOG = LoggerFactory.getLogger(MessagesTest.class); + + @TempDir + Path overrides; private static String plain(Component component) { return PlainTextComponentSerializer.plainText().serialize(component); } - @Test - void bothLanguagesDefineTheSameKeys() { - Set english = new TreeSet<>(messages.keys(Locale.ENGLISH)); - Set german = new TreeSet<>(messages.keys(Locale.GERMAN)); + private static Properties builtIn(String language) throws IOException { + Properties properties = new Properties(); + try (InputStream in = MessagesTest.class.getResourceAsStream("/lang/messages_" + language + ".properties")) { + properties.load(new InputStreamReader(in, StandardCharsets.UTF_8)); + } + return properties; + } - assertEquals(english, german, "every key must exist in messages_en and messages_de"); + private void write(String fileName, String content) throws IOException { + Files.writeString(overrides.resolve(fileName), content, StandardCharsets.UTF_8); + } + + private Messages messages() { + return Messages.load(overrides, LOG); } @Test void everyProblemCodeHasAMessageInBothLanguages() { + Messages messages = messages(); for (String code : PROBLEM_CODES) { String key = "problem." + code; assertTrue(messages.hasKey(Locale.ENGLISH, key), "English is missing " + key); @@ -49,28 +71,67 @@ void everyProblemCodeHasAMessageInBothLanguages() { @Test void unavailableHasAMessageInBothLanguages() { - assertTrue(messages.hasKey(Locale.ENGLISH, "problem.unavailable")); - assertTrue(messages.hasKey(Locale.GERMAN, "problem.unavailable")); + assertTrue(messages().hasKey(Locale.ENGLISH, "problem.unavailable")); + assertTrue(messages().hasKey(Locale.GERMAN, "problem.unavailable")); } @Test void germanClientsGetGermanText() { - String text = plain(messages.problem(Locale.GERMAN, new QueleaProblemException("not-leader", 403, null))); + String text = plain(messages().problem(Locale.GERMAN, new QueleaProblemException("not-leader", 403, null))); assertTrue(text.contains("Leiter"), "German text: " + text); assertFalse(text.contains("leader"), "no English text: " + text); } @Test - void otherLanguagesGetEnglishText() { - String text = plain(messages.problem(Locale.FRENCH, new QueleaProblemException("not-leader", 403, null))); + void germanRegionalClientGetsGermanText() { + String text = plain(messages().problem(Locale.GERMANY, new QueleaProblemException("not-leader", 403, null))); + + assertTrue(text.contains("Leiter"), "de_DE resolves to de: " + text); + } + + @Test + void unknownLanguageGetsEnglishText() { + String text = plain(messages().problem(Locale.FRENCH, new QueleaProblemException("not-leader", 403, null))); assertTrue(text.contains("leader"), "English fallback: " + text); } + @Test + void overrideFileAddsALanguageAndMissingKeysFallBackToEnglish() throws IOException { + write("messages_fr.properties", "party.joined= a rejoint la party.\n"); + + Messages messages = messages(); + + assertEquals("Bob a rejoint la party.", plain(messages.line(Locale.FRANCE, "party.joined", + Placeholder.unparsed("player", "Bob")))); + assertEquals("Bob left the party.", plain(messages.line(Locale.FRANCE, "party.left", + Placeholder.unparsed("player", "Bob"))), "a key the override does not define is English"); + } + + @Test + void overrideFileRecoloursOneKeyOfABuiltInLanguage() throws IOException { + write("messages_de.properties", "prefix=Party »\n"); + + Messages messages = messages(); + String joined = plain(messages.message(Locale.GERMAN, "command.accept.done")); + + assertTrue(joined.startsWith("Party »"), joined); + assertTrue(joined.contains("Du bist der Party beigetreten."), "other German texts stay: " + joined); + } + + @Test + void brokenOverrideTemplateFallsBackToTheEnglishText() throws IOException { + write("messages_de.properties", "party.joined=broken\n"); + + String text = plain(messages().line(Locale.GERMAN, "party.joined", Placeholder.unparsed("player", "Bob"))); + + assertEquals("Bob joined the party.", text); + } + @Test void unknownProblemCodeFallsBackToAGenericMessage() { - String text = plain(messages.problem(Locale.ENGLISH, new QueleaProblemException("brand-new-code", 409, null))); + String text = plain(messages().problem(Locale.ENGLISH, new QueleaProblemException("brand-new-code", 409, null))); assertTrue(text.contains("brand-new-code"), "the code is shown for support: " + text); } @@ -78,24 +139,53 @@ void unknownProblemCodeFallsBackToAGenericMessage() { @Test void expiryMessagesExistInBothLanguages() { for (String key : List.of("party.expiring", "party.disbanded.expired-alone")) { - assertTrue(messages.hasKey(Locale.ENGLISH, key), "English is missing " + key); - assertTrue(messages.hasKey(Locale.GERMAN, key), "German is missing " + key); + assertTrue(messages().hasKey(Locale.ENGLISH, key), "English is missing " + key); + assertTrue(messages().hasKey(Locale.GERMAN, key), "German is missing " + key); } } @Test void germanExpiryMessageNamesTheMinutesLeft() { - String text = plain(messages.line(Locale.GERMAN, "party.expiring", Placeholder.unparsed("minutes", "5"))); + String text = plain(messages().line(Locale.GERMAN, "party.expiring", Placeholder.unparsed("minutes", "5"))); assertTrue(text.contains("5 Minuten"), "German text: " + text); } @Test void playerTextIsNeverReadAsMiniMessage() { - Component line = messages.line(Locale.ENGLISH, "party.chat", + Component line = messages().line(Locale.ENGLISH, "party.chat", Placeholder.unparsed("sender", "Alice"), Placeholder.unparsed("message", "not red")); assertTrue(plain(line).endsWith("not red"), plain(line)); } + + @Test + void bothBuiltInBundlesDefineExactlyTheEnglishKeys() throws IOException { + Set english = new TreeSet<>(builtIn("en").stringPropertyNames()); + Set german = new TreeSet<>(builtIn("de").stringPropertyNames()); + + assertEquals(english, german, "every key must exist in messages_en and messages_de"); + } + + @Test + void everyBuiltInTemplateIsValidMiniMessage() throws IOException { + MiniMessage miniMessage = MiniMessage.miniMessage(); + for (String language : List.of("en", "de")) { + Properties bundle = builtIn(language); + for (String key : bundle.stringPropertyNames()) { + String template = bundle.getProperty(key); + assertTrue(parses(miniMessage, template), language + " " + key + " does not parse: " + template); + } + } + } + + private static boolean parses(MiniMessage miniMessage, String template) { + try { + miniMessage.deserialize(template); + return true; + } catch (RuntimeException e) { + return false; + } + } }