feat: hover translation (Google, Chrome endpoint with gtx fallback), LuckPerms prefixes in chat, restyled messages
Some checks are pending
Build & Test / Build LoChat (push) Waiting to run

This commit is contained in:
loki5512344 2026-09-20 21:05:17 +02:00
parent bd39118b14
commit 260986ff11
Signed by: boba
GPG key ID: 253067914055423B
9 changed files with 396 additions and 81 deletions

View file

@ -60,6 +60,8 @@ public final class LoChat extends JavaPlugin {
configManager.getTranslationEndpoint(), configManager.getTranslationEndpoint(),
configManager.getTranslationApiKey(), configManager.getTranslationApiKey(),
configManager.isTranslationEnabled(), configManager.isTranslationEnabled(),
configManager.getTranslationProvider(),
configManager.getTranslationTimeoutMs(),
getLogger() getLogger()
); );

View file

@ -249,6 +249,26 @@ public class ConfigManager {
return accessor.getTranslationApiKey(); return accessor.getTranslationApiKey();
} }
public String getTranslationProvider() {
return accessor.getTranslationProvider();
}
public String getTranslationMode() {
return accessor.getTranslationMode();
}
public int getTranslationTimeoutMs() {
return accessor.getTranslationTimeoutMs();
}
public java.util.List<String> getTranslationHoverFormat() {
return accessor.getTranslationHoverFormat();
}
public String getTranslationBypassPermission() {
return accessor.getTranslationBypassPermission();
}
// ========== LoPreff integration ========== // ========== LoPreff integration ==========
public boolean isLopreffEnabled() { public boolean isLopreffEnabled() {

View file

@ -150,6 +150,29 @@ public class ConfigAccessor {
return config.getString("chat.translation.api-key", ""); return config.getString("chat.translation.api-key", "");
} }
/** "libretranslate" (needs endpoint and usually a key) or "google" (free, no key). */
public String getTranslationProvider() {
return config.getString("chat.translation.provider", "libretranslate");
}
/** "hover" shows the translation when hovering the message, "actionbar" shows it above the hotbar. */
public String getTranslationMode() {
return config.getString("chat.translation.mode", "hover");
}
public int getTranslationTimeoutMs() {
return config.getInt("chat.translation.timeout-ms", 3000);
}
public java.util.List<String> getTranslationHoverFormat() {
java.util.List<String> lines = config.getStringList("chat.translation.hover-format");
return lines.isEmpty() ? java.util.List.of("&7Translation:", "&f{translation}") : lines;
}
public String getTranslationBypassPermission() {
return config.getString("chat.translation.bypass-permission", "lochat.translate.bypass");
}
// ========== LoPreff integration ========== // ========== LoPreff integration ==========
public boolean isLopreffEnabled() { public boolean isLopreffEnabled() {

View file

@ -9,7 +9,9 @@ import com.loki.lochat.compat.Msg;
import com.loki.lochat.core.filter.AdvancedMessageFilter; import com.loki.lochat.core.filter.AdvancedMessageFilter;
import com.loki.lochat.core.filter.FilterResult; import com.loki.lochat.core.filter.FilterResult;
import com.loki.lochat.renderer.EnhancedChatRenderer; import com.loki.lochat.renderer.EnhancedChatRenderer;
import com.loki.lochat.translate.TranslationService;
import com.loki.lochat.utils.format.ChatFormatter; import com.loki.lochat.utils.format.ChatFormatter;
import com.loki.lochat.utils.format.LuckPermsAffixes;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
@ -80,6 +82,7 @@ public class ChatEventListener implements Listener {
Component displayName = buildDisplayName(sender); Component displayName = buildDisplayName(sender);
Set<Player> recipients = new HashSet<>(event.getRecipients()); Set<Player> recipients = new HashSet<>(event.getRecipients());
event.setCancelled(true); event.setCancelled(true);
prefetchTranslation(sender, messageComponent, recipients);
for (Player viewer : recipients) { for (Player viewer : recipients) {
Component rendered = renderer.render(sender, displayName, messageComponent, viewer); Component rendered = renderer.render(sender, displayName, messageComponent, viewer);
Msg.send(viewer, rendered); Msg.send(viewer, rendered);
@ -132,6 +135,7 @@ public class ChatEventListener implements Listener {
Component displayName = buildDisplayName(sender); Component displayName = buildDisplayName(sender);
Set<Player> recipients = new HashSet<>(event.getRecipients()); Set<Player> recipients = new HashSet<>(event.getRecipients());
event.setCancelled(true); event.setCancelled(true);
prefetchTranslation(sender, messageComponent, recipients);
for (Player viewer : recipients) { for (Player viewer : recipients) {
Component rendered = renderer.render(sender, displayName, messageComponent, viewer); Component rendered = renderer.render(sender, displayName, messageComponent, viewer);
Msg.send(viewer, rendered); Msg.send(viewer, rendered);
@ -226,9 +230,37 @@ public class ChatEventListener implements Listener {
if (legacyName == null || legacyName.isBlank()) { if (legacyName == null || legacyName.isBlank()) {
return Component.text(sender.getName()); return Component.text(sender.getName());
} }
// LuckPerms prefix/suffix are added separately ({player_prefix}/{player_suffix}), do not show them twice
legacyName = LuckPermsAffixes.withoutAffixes(legacyName, LuckPermsAffixes.prefix(sender),
LuckPermsAffixes.suffix(sender));
return ChatFormatter.parse(legacyName); return ChatFormatter.parse(legacyName);
} }
/** Translates the message for every viewer language before rendering (runs off the main thread only). */
private void prefetchTranslation(Player sender, Component messageComponent, Set<Player> recipients) {
try {
LoChat loChat = (LoChat) plugin;
var cfg = loChat.getConfigManager();
TranslationService ts = loChat.getTranslationService();
if (!cfg.isTranslationEnabled() || !ts.isEnabled() || Bukkit.isPrimaryThread()
|| "actionbar".equalsIgnoreCase(cfg.getTranslationMode())
|| sender.hasPermission(cfg.getTranslationBypassPermission())) {
return;
}
Set<String> languages = new HashSet<>();
for (Player viewer : recipients) {
if (!viewer.equals(sender)) {
languages.add(TranslationService.localeToLanguageCode(viewer.getLocale()));
}
}
if (!languages.isEmpty()) {
ts.prefetch(Msg.plain(messageComponent), languages, cfg.getTranslationTimeoutMs());
}
} catch (Exception e) {
plugin.getLogger().fine("Translation prefetch failed: " + e.getMessage());
}
}
private void recordStatistics(LoChat loChat, Player sender, boolean isGlobal) { private void recordStatistics(LoChat loChat, Player sender, boolean isGlobal) {
playerService.recordMessage(sender.getUniqueId(), isGlobal ? "global" : "local"); playerService.recordMessage(sender.getUniqueId(), isGlobal ? "global" : "local");
} }

View file

@ -64,12 +64,7 @@ public class EnhancedChatRenderer {
if (viewer != null) { if (viewer != null) {
processed = mentionHandler.processMentions(processed, source, viewer); processed = mentionHandler.processMentions(processed, source, viewer);
if (cfg.isTranslationEnabled()) { processed = applyTranslation(source, viewer, message, processed);
com.loki.lochat.LoChat loChat = (com.loki.lochat.LoChat) plugin;
if (loChat.getTranslationService().isEnabled()) {
sendTranslationAsync(viewer, PlainTextComponentSerializer.plainText().serialize(processed));
}
}
} }
// Получаем формат из конфига // Получаем формат из конфига
@ -124,7 +119,7 @@ public class EnhancedChatRenderer {
while (matcher.find()) { while (matcher.find()) {
if (matcher.start() > lastEnd) { if (matcher.start() > lastEnd) {
result = result.append(Component.text(format.substring(lastEnd, matcher.start()))); result = result.append(ChatFormatter.parse(format.substring(lastEnd, matcher.start())));
} }
String placeholder = matcher.group(1); String placeholder = matcher.group(1);
@ -134,7 +129,7 @@ public class EnhancedChatRenderer {
} }
if (lastEnd < format.length()) { if (lastEnd < format.length()) {
result = result.append(Component.text(format.substring(lastEnd))); result = result.append(ChatFormatter.parse(format.substring(lastEnd)));
} }
return result; return result;
@ -156,6 +151,11 @@ public class EnhancedChatRenderer {
} }
} }
String luckPermsPrefix = com.loki.lochat.utils.format.LuckPermsAffixes.prefix(player);
if (!luckPermsPrefix.isEmpty()) {
return com.loki.lochat.utils.format.ChatFormatter.parse(luckPermsPrefix);
}
return Component.empty(); return Component.empty();
} }
@ -170,6 +170,11 @@ public class EnhancedChatRenderer {
} }
} }
String luckPermsSuffix = com.loki.lochat.utils.format.LuckPermsAffixes.suffix(player);
if (!luckPermsSuffix.isEmpty()) {
return com.loki.lochat.utils.format.ChatFormatter.parse(luckPermsSuffix);
}
return Component.empty(); return Component.empty();
} }
@ -239,6 +244,50 @@ public class EnhancedChatRenderer {
.clickEvent(ClickEvent.suggestCommand("/msg " + player.getName() + " ")); .clickEvent(ClickEvent.suggestCommand("/msg " + player.getName() + " "));
} }
/**
* Adds the translation into the viewer's client language: as hover text over the message (default)
* or in the action bar. The translation must already be cached (see ChatEventListener#prefetchTranslation).
*/
private Component applyTranslation(Player source, Player viewer, Component original, Component processed) {
if (!cfg.isTranslationEnabled() || viewer.equals(source)) {
return processed;
}
com.loki.lochat.LoChat loChat = (com.loki.lochat.LoChat) plugin;
TranslationService ts = loChat.getTranslationService();
if (!ts.isEnabled() || source.hasPermission(cfg.getTranslationBypassPermission())) {
return processed;
}
String plain = PlainTextComponentSerializer.plainText().serialize(original);
if (plain.isBlank()) {
return processed;
}
if ("actionbar".equalsIgnoreCase(cfg.getTranslationMode())) {
sendTranslationAsync(viewer, plain);
return processed;
}
String translated = ts.getCachedTranslation(plain, viewer.getLocale());
if (translated == null || translated.isBlank() || translated.equalsIgnoreCase(plain)) {
return processed;
}
Component hover = Component.empty();
boolean first = true;
for (String line : cfg.getTranslationHoverFormat()) {
if (!first) {
hover = hover.append(Component.newline());
}
first = false;
int idx = line.indexOf("{translation}");
if (idx < 0) {
hover = hover.append(ChatFormatter.parse(line));
} else {
hover = hover.append(ChatFormatter.parse(line.substring(0, idx)))
.append(Component.text(translated))
.append(ChatFormatter.parse(line.substring(idx + "{translation}".length())));
}
}
return processed.hoverEvent(HoverEvent.showText(hover));
}
private void sendTranslationAsync(Player viewer, String plainText) { private void sendTranslationAsync(Player viewer, String plainText) {
if (plainText.isBlank()) { if (plainText.isBlank()) {
return; return;

View file

@ -33,11 +33,20 @@ public class TranslationService {
private final String endpoint; private final String endpoint;
private final String apiKey; private final String apiKey;
private final boolean enabled; private final boolean enabled;
private final String provider;
private final int timeoutMs;
public TranslationService(String endpoint, String apiKey, boolean enabled, Logger logger) { public TranslationService(String endpoint, String apiKey, boolean enabled, Logger logger) {
this(endpoint, apiKey, enabled, "libretranslate", 3000, logger);
}
public TranslationService(String endpoint, String apiKey, boolean enabled, String provider, int timeoutMs,
Logger logger) {
this.endpoint = endpoint != null ? endpoint.replaceAll("/+$", "") : ""; this.endpoint = endpoint != null ? endpoint.replaceAll("/+$", "") : "";
this.apiKey = apiKey; this.apiKey = apiKey;
this.enabled = enabled; this.enabled = enabled;
this.provider = provider == null ? "libretranslate" : provider.toLowerCase(Locale.ROOT);
this.timeoutMs = Math.max(500, timeoutMs);
this.logger = logger; this.logger = logger;
this.httpClient = HttpClient.newBuilder() this.httpClient = HttpClient.newBuilder()
.connectTimeout(TIMEOUT) .connectTimeout(TIMEOUT)
@ -59,8 +68,37 @@ public class TranslationService {
}); });
} }
private boolean isGoogle() {
return "google".equals(provider);
}
public boolean isEnabled() { public boolean isEnabled() {
return enabled && !endpoint.isBlank(); return enabled && (isGoogle() || !endpoint.isBlank());
}
/** Cached translation for the given language, or null if it has not been translated yet. */
public String getCachedTranslation(String text, String language) {
return getCached(text, localeToLanguageCode(language));
}
/**
* Translates the text into every given language in parallel and waits up to timeoutMs.
* Call it off the main thread; the results end up in the cache.
*/
public void prefetch(String text, java.util.Collection<String> languages, long timeoutMs) {
if (!isEnabled() || text == null || text.isBlank()) {
return;
}
java.util.List<CompletableFuture<String>> futures = new java.util.ArrayList<>();
for (String language : languages) {
futures.add(translateAsync(text, language));
}
try {
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.get(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS);
} catch (Exception e) {
// timeout or failure: the message is simply sent without a translation
}
} }
public CompletableFuture<String> translateAsync(String text, String targetLanguage) { public CompletableFuture<String> translateAsync(String text, String targetLanguage) {
@ -89,7 +127,91 @@ public class TranslationService {
} }
} }
private volatile long gtxBlockedUntil;
private volatile long chromeBlockedUntil;
/**
* The gtx endpoint gets IP-blocked (HTTP 429 / captcha page) very quickly, so the Chrome dictionary
* endpoint is tried first and gtx is only a fallback; a blocked endpoint is skipped for a minute.
*/
private String doFetchGoogle(String text, String targetLang) throws Exception {
long now = System.currentTimeMillis();
String q = java.net.URLEncoder.encode(text, StandardCharsets.UTF_8);
String tl = java.net.URLEncoder.encode(targetLang, StandardCharsets.UTF_8);
Exception last = null;
if (now >= chromeBlockedUntil) {
try {
return parseChromeResponse(get("https://clients5.google.com/translate_a/t?client=dict-chrome-ex&sl=auto&tl="
+ tl + "&q=" + q));
} catch (Exception e) {
chromeBlockedUntil = now + 60_000;
last = e;
}
}
if (now >= gtxBlockedUntil) {
try {
return parseGoogleResponse(get("https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&dt=t&tl="
+ tl + "&q=" + q));
} catch (Exception e) {
gtxBlockedUntil = now + 60_000;
last = e;
}
}
throw last != null ? last : new RuntimeException("all translation endpoints are on cooldown");
}
private String get(String url) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofMillis(timeoutMs))
.header("User-Agent", "Mozilla/5.0")
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("Google returned " + response.statusCode());
}
return response.body();
}
/** Chrome dictionary response: [["translated","detected-language"], ...]. */
static String parseChromeResponse(String body) {
com.google.gson.JsonArray root = JsonParser.parseString(body).getAsJsonArray();
StringBuilder sb = new StringBuilder();
for (com.google.gson.JsonElement segment : root) {
if (segment.isJsonArray()) {
com.google.gson.JsonArray parts = segment.getAsJsonArray();
if (parts.size() > 0 && !parts.get(0).isJsonNull()) {
sb.append(parts.get(0).getAsString());
}
} else if (segment.isJsonPrimitive()) {
sb.append(segment.getAsString());
break;
}
}
if (sb.length() == 0) {
throw new RuntimeException("empty translation response");
}
return sb.toString();
}
/** Google response: [[["translated","original",...], ...], null, "detected-language"]. */
static String parseGoogleResponse(String body) {
com.google.gson.JsonArray root = JsonParser.parseString(body).getAsJsonArray();
StringBuilder sb = new StringBuilder();
for (com.google.gson.JsonElement segment : root.get(0).getAsJsonArray()) {
com.google.gson.JsonArray parts = segment.getAsJsonArray();
if (parts.size() > 0 && !parts.get(0).isJsonNull()) {
sb.append(parts.get(0).getAsString());
}
}
return sb.toString();
}
private String doFetch(String text, String targetLang) throws Exception { private String doFetch(String text, String targetLang) throws Exception {
if (isGoogle()) {
return doFetchGoogle(text, targetLang);
}
JsonObject body = new JsonObject(); JsonObject body = new JsonObject();
body.addProperty("q", text); body.addProperty("q", text);
body.addProperty("source", "auto"); body.addProperty("source", "auto");
@ -141,12 +263,12 @@ public class TranslationService {
if (locale == null || locale.isBlank()) { if (locale == null || locale.isBlank()) {
return "en"; return "en";
} }
String lang = locale.split("_")[0].toLowerCase(Locale.ROOT); String normalized = locale.toLowerCase(Locale.ROOT).replace('-', '_');
return switch (lang) { if (normalized.startsWith("zh_tw") || normalized.startsWith("zh_hk")) {
case "zh" -> "zh-CN"; return "zh-TW";
case "zh-tw", "zh-hk" -> "zh-TW"; }
default -> lang; String lang = normalized.split("_")[0];
}; return "zh".equals(lang) ? "zh-CN" : lang;
} }
public void clearCache() { public void clearCache() {

View file

@ -0,0 +1,67 @@
package com.loki.lochat.utils.format;
import me.clip.placeholderapi.PlaceholderAPI;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.regex.Pattern;
/**
* LuckPerms prefix and suffix through PlaceholderAPI (%luckperms_prefix% / %luckperms_suffix%).
* Returns an empty string when PlaceholderAPI or its LuckPerms expansion is not available.
*/
public final class LuckPermsAffixes {
private static final Pattern COLOR = Pattern.compile("(?i)(&#[0-9a-f]{6}|[&\u00A7][0-9a-fk-orx])");
private LuckPermsAffixes() {
}
public static String prefix(Player player) {
return resolve(player, "%luckperms_prefix%");
}
public static String suffix(Player player) {
return resolve(player, "%luckperms_suffix%");
}
private static String resolve(Player player, String placeholder) {
if (player == null || !Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) {
return "";
}
try {
String result = PlaceholderAPI.setPlaceholders(player, placeholder);
if (result == null || result.contains("%")) {
return "";
}
return result;
} catch (Exception | LinkageError e) {
return "";
}
}
/** Text without color codes. */
public static String strip(String text) {
return text == null ? "" : COLOR.matcher(text).replaceAll("");
}
/**
* Removes the LuckPerms prefix/suffix from a display name that already contains them
* (for example when another plugin put them into the nickname), so they are not shown twice.
* Returns the original text if nothing was removed.
*/
public static String withoutAffixes(String displayName, String prefix, String suffix) {
String plain = strip(displayName).trim();
String pp = strip(prefix).trim();
String sp = strip(suffix).trim();
String result = plain;
if (!pp.isEmpty() && result.startsWith(pp)) {
result = result.substring(pp.length()).trim();
}
if (!sp.isEmpty() && result.endsWith(sp)) {
result = result.substring(0, result.length() - sp.length()).trim();
}
return result.equals(plain) || result.isEmpty() ? displayName : result;
}
}

View file

@ -5,25 +5,25 @@
commands: commands:
mute: mute:
usage: "&cUsage: &f/mute <player> [time] [-s] [reason]" usage: "&cUsage: &6/lmute <player> [time] [-s] [reason]"
time-help: "&7Time format: &f1d&7, &f2h&7, &f30m&7, &f60s&7, &fperm" time-help: "&7Time format: &61d&7, &62h&7, &630m&7, &660s&7, &6perm"
silent-help: "&7-s &7= silent mute (requires &flochat.mute.silent&7)" silent-help: "&7Use &6-s &7for a silent mute (needs &6lochat.mute.silent&7)"
no-silent-permission: "&cNo permission for silent mute" no-silent-permission: "&cNo permission for silent mute"
default-reason: "No reason specified" default-reason: "No reason specified"
default-duration: "7d" default-duration: "7d"
cooldown: cooldown:
message: "&cPlease wait &f{remaining} &cseconds" message: "&cPlease wait &6{remaining} &cseconds"
clear-chat: clear-chat:
lines-cleared: "&aChat cleared &7({lines} lines)" lines-cleared: "&fChat cleared &7({lines} lines)"
no-permission: "&cNo permission to clear chat" no-permission: "&cNo permission to clear chat"
# ── Local Chat ───────────────────────────────────────────────────────────────── # ── Local Chat ─────────────────────────────────────────────────────────────────
local-chat: local-chat:
nobody-heard: "&7Nobody heard you — no players nearby" nobody-heard: "&7Nobody heard you. Start your message with &6! &7to be heard everywhere."
no-players-nearby: "&7No players nearby" no-players-nearby: "&7No players nearby. Start your message with &6! &7to write to everyone."
# ── Filters ──────────────────────────────────────────────────────────────────── # ── Filters ────────────────────────────────────────────────────────────────────
@ -42,13 +42,13 @@ filters:
# ── Mute Messages ────────────────────────────────────────────────────────────── # ── Mute Messages ──────────────────────────────────────────────────────────────
mute: mute:
muted-message: "&cYou are muted! &7Reason: &f{reason}&7. Remaining: &f{time}" muted-message: "&cYou are muted! &7Reason: &6{reason}&7. Remaining: &6{time}"
muted-permanent: "&cYou are permanently muted! &7Reason: &f{reason}" muted-permanent: "&cYou are permanently muted! &7Reason: &6{reason}"
player-muted: "&aPlayer &f{player} &amuted for &f{time}&a. Reason: &7{reason}" player-muted: "&fPlayer &6{player} &fmuted for &6{time}&f. Reason: &7{reason}"
player-muted-permanent: "&aPlayer &f{player} &amuted permanently. Reason: &7{reason}" player-muted-permanent: "&fPlayer &6{player} &fmuted permanently. Reason: &7{reason}"
player-muted-silent: "&8[&7SILENT&8] &f{player} &7muted for &f{time}" player-muted-silent: "&8[&7SILENT&8] &6{player} &7muted for &6{time}"
player-unmuted: "&aPlayer &f{player} &aunmuted" player-unmuted: "&fPlayer &6{player} &funmuted"
not-muted: "&7Player &f{player} &7is not muted" not-muted: "&7Player &6{player} &7is not muted"
player-not-found: "&cPlayer not found!" player-not-found: "&cPlayer not found!"
invalid-time: "&cInvalid time format!" invalid-time: "&cInvalid time format!"
already-muted: "&cPlayer is already muted!" already-muted: "&cPlayer is already muted!"
@ -66,17 +66,17 @@ gamemodes:
private-messages: private-messages:
no-reply-target: "&cNobody to reply to" no-reply-target: "&cNobody to reply to"
message-sent: "&aMessage sent to &f{player}" message-sent: "&fMessage sent to &6{player}"
player-offline: "&cPlayer &f{player} &cis not online" player-offline: "&cPlayer &6{player} &cis not online"
cannot-message-self: "&cYou cannot message yourself" cannot-message-self: "&cYou cannot message yourself"
# ── Ignore ───────────────────────────────────────────────────────────────────── # ── Ignore ─────────────────────────────────────────────────────────────────────
ignore: ignore:
player-ignored: "&aPlayer &f{player} &ais now ignored" player-ignored: "&fPlayer &6{player} &fis now ignored"
player-unignored: "&aPlayer &f{player} &ais no longer ignored" player-unignored: "&fPlayer &6{player} &fis no longer ignored"
already-ignored: "&7Player &f{player} &7is already ignored" already-ignored: "&7Player &6{player} &7is already ignored"
not-ignored: "&7Player &f{player} &7is not ignored" not-ignored: "&7Player &6{player} &7is not ignored"
cannot-ignore-self: "&cYou cannot ignore yourself" cannot-ignore-self: "&cYou cannot ignore yourself"
ignore-list-empty: "&7Your ignore list is empty" ignore-list-empty: "&7Your ignore list is empty"
ignore-list-header: "&7Ignored players:" ignore-list-header: "&7Ignored players:"
@ -97,7 +97,7 @@ general:
days: "d" days: "d"
permanent: "permanent" permanent: "permanent"
enabled: "&aEnabled" enabled: "&aEnabled"
disabled: "&7Disabled" disabled: "&cDisabled"
online: "&aOnline" online: "&aOnline"
offline: "&7Offline" offline: "&7Offline"

View file

@ -1,20 +1,20 @@
# LoChat player messages # LoChat player messages
# All messages sent to players. Supports & color codes and placeholders. # All messages sent to players. Supports & color codes and placeholders.
prefix: "&8[&3LoChat&8]" prefix: "&6Star Wars &7|"
# ── Global Chat ──────────────────────────────────────────────────────────────── # ── Global Chat ────────────────────────────────────────────────────────────────
global: global:
toggled-on: "&aGlobal chat enabled" toggled-on: "&fGlobal chat enabled"
toggled-off: "&cGlobal chat disabled" toggled-off: "&cGlobal chat disabled"
disabled: "&cGlobal chat is disabled on this server" disabled: "&cGlobal chat is disabled on this server"
# ── Local Chat ───────────────────────────────────────────────────────────────── # ── Local Chat ─────────────────────────────────────────────────────────────────
local: local:
no-players: "&cNo players nearby" no-players: "&7No players nearby. Start your message with &6! &7to write to everyone."
nobody-heard: "&7Nobody heard you — no players nearby" nobody-heard: "&7Nobody heard you. Start your message with &6! &7to be heard everywhere."
# ── Private Messages ─────────────────────────────────────────────────────────── # ── Private Messages ───────────────────────────────────────────────────────────
@ -29,19 +29,19 @@ pm:
# ── Ignore List ──────────────────────────────────────────────────────────────── # ── Ignore List ────────────────────────────────────────────────────────────────
ignore: ignore:
added: "&aPlayer &f{player} &ais now ignored" added: "&fPlayer &6{player} &fis now ignored"
removed: "&aPlayer &f{player} &ais no longer ignored" removed: "&fPlayer &6{player} &fis no longer ignored"
already-ignored: "&7Player &f{player} &7is already ignored" already-ignored: "&7Player &6{player} &7is already ignored"
not-ignored: "&7Player &f{player} &7is not ignored" not-ignored: "&7Player &6{player} &7is not ignored"
self: "&cYou cannot ignore yourself" self: "&cYou cannot ignore yourself"
list-empty: "&7Your ignore list is empty" list-empty: "&7Your ignore list is empty"
list-header: "&8\u2500\u2500\u2500\u2500\u2500 Ignored Players \u2500\u2500\u2500\u2500\u2500" list-header: "&8\u2500\u2500\u2500\u2500\u2500 Ignored Players \u2500\u2500\u2500\u2500\u2500"
list-footer: "&7Total: &f{count} &7players" list-footer: "&7Total: &6{count} &7players"
# ── Announcements ────────────────────────────────────────────────────────────── # ── Announcements ──────────────────────────────────────────────────────────────
announce: announce:
reloaded: "&aConfiguration reloaded" reloaded: "&fConfiguration reloaded"
# ── Chat Spy ─────────────────────────────────────────────────────────────────── # ── Chat Spy ───────────────────────────────────────────────────────────────────
@ -60,7 +60,7 @@ filter:
# ── Cooldowns ────────────────────────────────────────────────────────────────── # ── Cooldowns ──────────────────────────────────────────────────────────────────
cooldown: cooldown:
wait: "&cPlease wait &f{time} &cseconds" wait: "&cPlease wait &6{time} &cseconds"
# ── Anti-Spam ────────────────────────────────────────────────────────────────── # ── Anti-Spam ──────────────────────────────────────────────────────────────────
@ -72,23 +72,23 @@ antispam:
# ── Clear Chat ───────────────────────────────────────────────────────────────── # ── Clear Chat ─────────────────────────────────────────────────────────────────
clearchat: clearchat:
cleared: "&aChat cleared by &f{player}" cleared: "&fChat cleared by &6{player}"
# ── Errors ───────────────────────────────────────────────────────────────────── # ── Errors ─────────────────────────────────────────────────────────────────────
errors: errors:
no-permission: "&cNo permission" no-permission: "&cNo permission"
player-not-found: "&cPlayer not found" player-not-found: "&cPlayer not found"
invalid-usage: "&cUsage: &f{usage}" invalid-usage: "&cUsage: &6{usage}"
# ── General ──────────────────────────────────────────────────────────────────── # ── General ────────────────────────────────────────────────────────────────────
general: general:
no-permission: "&cYou don't have permission!" no-permission: "&cYou don't have permission!"
players-only: "&cThis command is for players only!" players-only: "&cThis command is for players only!"
config-reloaded: "&aConfiguration reloaded!" config-reloaded: "&fConfiguration reloaded!"
invalid-player: "&cPlayer &f{player} &cnot found!" invalid-player: "&cPlayer &6{player} &cnot found!"
cooldown-active: "&cPlease wait &f{time}&cs" cooldown-active: "&cPlease wait &6{time}&cs"
command-disabled: "&cCommand disabled!" command-disabled: "&cCommand disabled!"
# ── Hub ──────────────────────────────────────────────────────────────────────── # ── Hub ────────────────────────────────────────────────────────────────────────
@ -99,55 +99,55 @@ hub:
# ── Mute ─────────────────────────────────────────────────────────────────────── # ── Mute ───────────────────────────────────────────────────────────────────────
mute: mute:
muted: "&aPlayer &f{player} &amuted for &f{time}" muted: "&fPlayer &6{player} &fmuted for &6{time}"
unmuted: "&aPlayer &f{player} &aunmuted" unmuted: "&fPlayer &6{player} &funmuted"
already-muted: "&7Player &f{player} &7is already muted" already-muted: "&7Player &6{player} &7is already muted"
not-muted: "&7Player &f{player} &7is not muted" not-muted: "&7Player &6{player} &7is not muted"
you-muted: "&cYou are muted! &7Remaining: &f{time}" you-muted: "&cYou are muted! &7Remaining: &6{time}"
permanent: "&cYou are permanently muted!" permanent: "&cYou are permanently muted!"
# ── Gradients ────────────────────────────────────────────────────────────────── # ── Gradients ──────────────────────────────────────────────────────────────────
gradient: gradient:
color-set: "&aGradient set!" color-set: "&fGradient set!"
color-enabled: "&aGradient nickname enabled" color-enabled: "&fGradient nickname enabled"
color-disabled: "&7Gradient nickname disabled" color-disabled: "&7Gradient nickname disabled"
color-reset: "&7Gradient reset" color-reset: "&7Gradient reset"
prefix-set: "&aPrefix set!" prefix-set: "&aPrefix set!"
prefix-reset: "&7Prefix reset" prefix-reset: "&7Prefix reset"
prefix-enabled: "&aPrefix enabled" prefix-enabled: "&fPrefix enabled"
prefix-disabled: "&7Prefix disabled" prefix-disabled: "&7Prefix disabled"
invalid-hex: "&cInvalid hex color: &f{color}" invalid-hex: "&cInvalid hex color: &6{color}"
invalid-color: "&cInvalid color: &f{color}" invalid-color: "&cInvalid color: &6{color}"
too-many-colors: "&cToo many colors! Maximum: &f{max}" too-many-colors: "&cToo many colors! Maximum: &6{max}"
too-few-colors: "&cToo few colors! Minimum: &f{min}" too-few-colors: "&cToo few colors! Minimum: &6{min}"
cooldown: "&cPlease wait &f{time} &cseconds" cooldown: "&cPlease wait &6{time} &cseconds"
not-enough-points: "&cNot enough points! Need: &f{price}" not-enough-points: "&cNot enough points! Need: &6{price}"
player-not-found: "&cPlayer not found" player-not-found: "&cPlayer not found"
color-help: "&cUsage: &f/color <hex1> <hex2> ..." color-help: "&cUsage: &6/color <hex1> <hex2> ..."
color-success: "&aGradient applied!" color-success: "&fGradient applied!"
color-no-colors: "&7You have no colors set" color-no-colors: "&7You have no colors set"
prefix-help: "&cUsage: &f/prefix <text>" prefix-help: "&cUsage: &6/prefix <text>"
prefix-success: "&aPrefix set!" prefix-success: "&aPrefix set!"
prefix-success-free: "&aPrefix set!" prefix-success-free: "&aPrefix set!"
prefix-no-prefix: "&7You have no prefix" prefix-no-prefix: "&7You have no prefix"
prefix-too-long: "&cPrefix too long! Max: &f{max}" prefix-too-long: "&cPrefix too long! Max: &6{max}"
prefix-no-colors: "&cPrefix cannot contain color codes" prefix-no-colors: "&cPrefix cannot contain color codes"
only-player: "&cPlayers only!" only-player: "&cPlayers only!"
no-permission: "&cNo permission!" no-permission: "&cNo permission!"
reload-success: "&aConfiguration reloaded!" reload-success: "&fConfiguration reloaded!"
info-header: "&8\u2500\u2500\u2500\u2500\u2500 &f{player} &8\u2500\u2500\u2500\u2500\u2500" info-header: "&8\u2500\u2500\u2500\u2500\u2500 &6{player} &8\u2500\u2500\u2500\u2500\u2500"
info-prefix: "&7Prefix: &f{prefix}" info-prefix: "&7Prefix: &6{prefix}"
info-colors: "&7Colors: &f{colors}" info-colors: "&7Colors: &6{colors}"
# ── Custom Nicknames ─────────────────────────────────────────────────────────── # ── Custom Nicknames ───────────────────────────────────────────────────────────
nick: nick:
current: "&7Your nickname: &f{nick}" current: "&7Your nickname: &6{nick}"
usage: "&cUsage: &f/nick <name> &7or &f/nick reset" usage: "&cUsage: &6/nick <name> &7or &6/nick reset"
no-nick: "&7You have no custom nickname" no-nick: "&7You have no custom nickname"
reset-success: "&aNickname reset!" reset-success: "&fNickname reset!"
set-success: "&aNickname changed to: &f{nick}" set-success: "&fNickname changed to: &6{nick}"
invalid: "&cInvalid nickname!" invalid: "&cInvalid nickname!"
taken: "&cThis nickname is already taken!" taken: "&cThis nickname is already taken!"
error: "&cFailed to set nickname!" error: "&cFailed to set nickname!"
@ -156,11 +156,11 @@ nick:
rp: rp:
me: "&7* {player} {action}" me: "&7* {player} {action}"
try-format: "&7* {player} tries {action}... &f{result}" try-format: "&7* {player} tries {action}... &6{result}"
try-success: "&a[Success!]" try-success: "&a[Success!]"
try-fail: "&c[Fail!]" try-fail: "&c[Fail!]"
do-format: "&8[Scene] &f{description} &7({player})" do-format: "&8[Scene] &6{description} &7({player})"
roll-format: "&7* {player} rolls &f{result} &7out of &f{max}" roll-format: "&7* {player} rolls &6{result} &7out of &6{max}"
roll-min-error: "&cMinimum dice value: 2" roll-min-error: "&cMinimum dice value: 2"
roll-max-error: "&cMaximum dice value: 1000000" roll-max-error: "&cMaximum dice value: 1000000"
roll-invalid-number: "&cPlease specify a number: /roll [max]" roll-invalid-number: "&cPlease specify a number: /roll [max]"