feat(translate): Add automatic message translation to player language#335
feat(translate): Add automatic message translation to player language#335SolverNA wants to merge 29 commits into
Conversation
|
Разве такой автоматический перевод не добавляет задержку в чат у сообщений? |
|
Да сег сделаю асинхронный перевод |
|
Так разве всёравно не будет задержки в сообщениях? Ведь после отправки сообщения оно должно быть переведено через API, а это даст задержку в отправлении сообщения |
|
Нет, мы не будем делать одним потоком всё. Сообщения отправляем как обычно оригиналом, и асинхронно отправляем запрос на перевод, перевод приходит, изменяем сообщение на переведённое. |
|
Идею я твою понял, как будет время я посмотрю твой PR, некоторые моменты можно упростить |
- Created TranslatedMessage model to store all message translations - Extended HistoryMessage to support original/translation toggle - Added /toggleoriginal command to switch between original and translation - Implemented automatic translation to all unique server locales (API request optimization) - Updated localizations: button now toggles original/translation instead of creating new message - Added PulseAutoTranslateListener to handle auto-translation in MessagePrepareEvent - Modified DeleteModule to work with translations and toggling Now each message is automatically translated to the receiver's language. Button on message allows switching between original and translation.
- Created TranslationCacheService with MyMemory API integration - Added TRANSLATION_CACHE (24h, 50000 entries) for persistent translation storage - Integrated MyMemory API as fallback before main translation service - Converted translateToAllLocales to async with CompletableFuture for parallel translations - Added useMyMemory config option (default: true) - Added MYMEMORY service type to translation services - Updated configs for minecraft and hytale platforms Benefits: - No chat delay: original message sent immediately, translations load in background - MyMemory provides free cached translations (10k requests/day) - Parallel translation requests instead of sequential - Local cache reduces API calls for repeated phrases
- ToggleOriginalModule: use registerCustomCommand instead of registerCommand
so /toggleoriginal is registered as a top-level command
- DeleteModule: prevent double-save of the same message UUID in history
- PulseAutoTranslateListener: replace ConcurrentHashMap with 30s Guava Cache
so all receivers get translation, not just the first one;
fix unsafe FEntity cast with instanceof check
- TranslationCacheService: recreate ExecutorService on shutdown() so async
translations survive plugin reload; reduce thread pool from 10 to 4
- TranslateModule: remove dead Map<UUID,TranslatedMessage> (memory leak)
|
Меня пока что смущает, что ты делаешь это с помощью ИИ, хоть он и понимает контекст проекта, но в некоторых местах избыточные проверки |
| public String translate(FPlayer fPlayer, String source, String target, String text) { | ||
| return switch (config().service()) { | ||
| // Check cache first if MyMemory is enabled | ||
| if (config().useMyMemory() != null && config().useMyMemory()) { |
There was a problem hiding this comment.
проверка на null бесполезна, оно всегда будет существовать, если вписано в файл конфигурации
| private final MessageDispatcher messageDispatcher; | ||
| private final ModuleController moduleController; | ||
| private final ModuleCommandController commandModuleController; | ||
| private final net.flectone.pulse.service.TranslationCacheService translationCacheService; |
There was a problem hiding this comment.
зачем указывать полный путь к классу?
There was a problem hiding this comment.
меня тоже бесит эта фигня в иишках)
|
я крч хотел с ии набросать а потом подправить сейчас крч довожу потом буду патчить |
В целом мне твоя идея нравится, но тебе нужно это доработать.
|
| public record HistoryMessage( | ||
| UUID uuid, | ||
| Component component, | ||
| @Nullable TranslatedMessage translatedMessage, | ||
| boolean showOriginal | ||
| ) { | ||
|
|
||
| public HistoryMessage(UUID uuid, Component component) { | ||
| this(uuid, component, null, false); | ||
| } | ||
|
|
||
| public HistoryMessage(UUID uuid, Component component, TranslatedMessage translatedMessage) { | ||
| this(uuid, component, translatedMessage, false); | ||
| } | ||
|
|
||
| /** | ||
| * Get the component to display based on showOriginal flag and player locale. | ||
| */ | ||
| public Component getDisplayComponent(String playerLocale) { | ||
| if (translatedMessage == null) { | ||
| return component; | ||
| } | ||
|
|
||
| if (showOriginal) { | ||
| return translatedMessage.getOriginal(); | ||
| } | ||
|
|
||
| return translatedMessage.getTranslation(playerLocale); | ||
| } | ||
|
|
||
| /** | ||
| * Check if this message has translations. | ||
| */ | ||
| public boolean hasTranslations() { | ||
| return translatedMessage != null; | ||
| } |
There was a problem hiding this comment.
мне до сих пор не понятно, зачем ты трогаешь delete модуль, если он никак не связан с твоей идеей. Он будет правильно отрабатывать в любом случае без всех этих проверок, потому что сообщения приходят пользователю через пакеты, которые FlectonePulse слушает
| DEEPL, | ||
| GOOGLE, | ||
| YANDEX | ||
| YANDEX, |
There was a problem hiding this comment.
зачем useMyMemory и отдельный сервис MYMEMORY?
There was a problem hiding this comment.
кажется я понял, ты сохраняешь переведённые сообщения в облаке, чтобы их не переводить, но у этого есть одно но: у тебя в любом случае это HTTP запрос, который занимает время и смысла от того, что ты это сохраняешь в облаке нет + сообщения редко повторяться будут.
Хранить переводы в облаке плохая идея, нужно только локально в кэше это делать, rate limit сложно будет получить от API перевода
There was a problem hiding this comment.
mymemory это сервис где есть уже готовые переводы для коротких фраз какбы онлайн кеш и мы вначале обрашаемся в локальный кеш потом в mymemory и уже потом запрос на перевод через api переводчиков
There was a problem hiding this comment.
mymemory это сервис где есть уже готовые переводы для коротких фраз какбы онлайн кеш и мы вначале обрашаемся в локальный кеш потом в mymemory и уже потом запрос на перевод через api переводчиков
Так а смысл, это тоже HTTP запрос, который будет задерживать отправление сообщения. Перевод текста моментальный от API, максимум можно бояться rate limit, но этого сложного достичь
There was a problem hiding this comment.
Можешь, конечно, реализацию эту оставить, но тогда её не нужно вписывать как enum, оставь только булеан
| } | ||
| in.close(); | ||
|
|
||
| // Parse JSON response: {"responseData":{"translatedText":"..."}} |
There was a problem hiding this comment.
в проекте есть gson, можно создать обёртку record и использовать gson.fromJson без костыльных парсингов
|
Гитхаб наспамил, ща пофиксим, во, норм |
event.withMessage(translatedComponent) replaced the fully formatted component (player nickname + colors + ⇄ button) with Component.text(plain_text), causing the chat to display a plain, unformatted message. Now we save event.message() (full format) to the translations history for toggleOriginal to work without affecting the message itself.
…ine. Logs at every stage for diagnosing why translation isn't working: - PulseAutoTranslateListener: PrepareEvent/SendEvent (inputs, skip reasons, cache miss/hits) - TranslateModule: translateToAllLocales (online players, locales, async startup), addTag (button resolver) - TranslationCacheService: HTTP requests to MyMemory (URL, code, body), cache GET/PUT, async tasks - PulseTranslateListener: FormattingEvent with flag parsing [AutoTranslate] and [Translate] prefixes for convenient grepping in server.log.
|
Привет! Разобрался, зачем ИИ использовал Я изначально думал, что плагин умеет изменять существующие сообщения в чате, и хотел использовать эту возможность так: оригинальное сообщение после асинхронного перевода заменяется на переведённое, а кнопка «Показать оригинал» — возвращает его обратно. Но посмотрел, как плагин работает на самом деле, и оказалось, что перевод приходит отдельным сообщением, а не редактирует существующее. Моя же идея была именно в изменении сообщения прямо в истории чата. Архитектура, которую предложил ИИ: Создаём
Это создаёт эффект изменения сообщения на клиенте, хотя технически это переотправка истории. Дополнительно — для оптимизации храним не полную историю для каждого игрока, а только пометки: должен ли конкретный игрок «видеть» то или иное сообщение в оригинале или переводе. Принцип похож на то, как устроен Git — полная история общая, а состояние для каждого игрока восстанавливается по этим меткам. Есть небольшие технические вопросы:
|
|
Структурировал через ИИ, чтобы было понятно. Мой текст просто оставляет желать лучшего. |
|
В майнкрафте нельзя изменять отправленное сообщение, потому что на клиенте это просто отрендеренный текст и там нет динамики, это как txt файл, только в красивой обёртке. Идея тоже прикольная, чтобы "изменять" переведённое сообщение, но не будет ли это слишком нагружать и сервер, и клиента не знаю. Если ты правда хочешь это довести до конца, то тебе не нужно изменять DeleteModule, ты должен реализовать свой механизм, который можешь по аналогии скопировать из delete модуля, потому что FlectonePulse имеет модульную структуру и каждые модули независимы друг от друга |
Move per-receiver chat history (playersHistory, cachedComponents) and the brute-force chat replay mechanism (sendUpdate) out of DeleteModule into a new shared ChatHistoryService. DeleteModule keeps its delete-specific logic (addTag, remove, toggleOriginal) but delegates all history storage and replay operations to ChatHistoryService. Public API is preserved — PulseDeleteListener, PulseAutoTranslateListener and ToggleOriginalModule continue to compile and behave identically. This sets up decoupling for the auto-translate feature in the follow-up commit: TranslateModule will use ChatHistoryService directly instead of reaching into DeleteModule (which was a layering violation flagged by the plugin author).
Per plugin author's request: keep modules self-contained, no cross-module dependencies. The previous translate feature reached into DeleteModule (save/sendUpdate/toggleOriginal) which violated the plugin's modular design. Reverts the following author-owned files to caa9551: - DeleteModule, HistoryMessage (delete/model) - TranslateModule, PulseTranslateListener - TranslatetoModule, Command, CommandModule - YAML configs and localizations (translate.action button restored to author's /translateto idiom, /toggleoriginal command removed) Removes ChatHistoryService (introduced in fedfc9c) — author rejected the shared-service approach; each module must own its state. Removes user-created files that depended on the removed extensions: - ToggleOriginalModule (called deleteModule.toggleOriginal) - PulseAutoTranslateListener (called translateModule.translateToAllLocales and deleteModule.save with translatedMessage) - TranslatedMessage record Preserved for the self-contained rebuild in follow-up commits: - TranslationCacheService (standalone HTTP + cache, plus the %7C URL fix) - CacheName.TRANSLATION_CACHE and ModuleName.COMMAND_TOGGLEORIGINAL (1-line additions needed for the rebuild)
TranslateModule now owns the full auto-translate flow without depending on
DeleteModule. Chat history mechanism is copied by analogy (per module author's
guidance: modules must stay independent, duplicate code over shared state).
Flow:
1. Player writes — original is dispatched immediately to all receivers (no
blocking, no delay).
2. PulseAutoTranslateListener.onMessagePrepareEvent kicks off async translation
for every unique online locale other than the sender's, via
TranslationCacheService.translateWithMyMemoryAsync (chain comes next commit).
3. PulseAutoTranslateListener.onMessageSendEvent records the original formatted
component into TranslateModule's own per-receiver history together with the
TranslatedMessage that will be populated by callbacks.
4. When each async translation lands, TranslateModule.replayForLocale triggers
a chat redraw for every online receiver whose locale matches — they had
the original; now they see the translation.
Per-receiver behavior:
- Same locale as sender → no translation, no toggle button (nothing to toggle).
- Different locale → sees translation after async lands, button toggles to
original via /toggleoriginal <message-uuid>.
- Console/non-player sender → no button (auto-translate only handles player chat).
ToggleOriginalModule recreated to call TranslateModule.toggleOriginal directly —
no DeleteModule reference. Reuses Deletemessage command config (cooldown / aliases
/ permission) since the plugin has no dedicated config record for this command.
YAML: translate.action button now runs /toggleoriginal <message> (en_us and
ru_ru localizations) — replaces the author's original /translateto idiom for
the auto-translate variant of the feature.
History length is borrowed from delete module config — both modules push out
the same number of empty newlines for the brute-force redraw.
Startup crash: CacheRegistry iterates every CacheName enum value and looks up its config in cache().types(). The TRANSLATION_CACHE enum value survived the revert (it's needed by TranslationCacheService) but the corresponding config.yml stanza was lost when author's resource files were restored. Re-add the same TRANSLATION_CACHE block originally introduced in ccccadc: invalidate_on_reload: false duration: 24 HOURS size: 50000 Applied to both minecraft and hytale default configs.
…howing as \uXXXX MyMemory returns non-ASCII characters as JSON unicode escapes (e.g. Cyrillic 'и' arrives as the literal 5 chars \u0438). The hand-rolled JSON parser only handled \n \t \\ \" escapes — \uXXXX fell through unchanged, so the cache and chat ended up showing the raw escape sequences. Replaced the manual indexOf-based parser with Gson, which handles all JSON string escapes uniformly. Also drops ~70 lines of custom parsing/escaping code. Project already pulls in Gson 2.8.0 as a shaded dep, so used the older 'new JsonParser().parse(...)' API for compatibility (parseString static method was added in 2.8.6).
…rillic correctly
Two related fixes that surfaced together during testing:
1) JSON unicode escape — MyMemory returns non-ASCII as \uXXXX in the JSON
payload (Cyrillic, CJK, etc.). The hand-rolled parser only handled \n
\t \\ \" escapes, so translations like 'и' arrived as the literal 6
chars '\u0438'. Replaced the custom parser with Gson (already a project
dep) which decodes every JSON string escape uniformly.
2) Plain-text replay — after a translation landed, sendUpdate redrew chat
using Component.text(translation), losing the chat format (nickname,
colors, ⇄ button). Now translations are kept as plain strings inside
TranslatedMessage; the rendered component is produced lazily in
TranslateHistoryMessage.getDisplayComponent via Component.replaceText —
swap original text literal with translation inside the existing component
tree. No pipeline rebuild needed; all surrounding formatting stays intact.
TranslateHistoryMessage now carries the raw player text alongside the
formatted component so replaceText has a literal to match. PulseAutoTranslateListener
passes eventMetadata.message() into save() for this purpose.
Map type in TranslatedMessage.translations changed from <String,Component>
to <String,String> — Components are no longer stored, only the text strings.
…n via config
MyMemory has been returning poor translations during testing — e.g.
'привет как дела' → 'salam necesen' (Azerbaijani), short English phrases
echoed back unchanged, plus a 1000 word/day per-IP rate limit. Switch the
default auto-translate provider to Google Translate's free public gtx
endpoint (no API key required).
Config: message.format.translate.use_my_memory (default false).
- false → Google Translate (translate.googleapis.com/translate_a/single)
- true → MyMemory (kept available for users who prefer it)
TranslationCacheService now exposes translateWithGoogleAsync alongside the
existing translateWithMyMemoryAsync; both share a small submitAsync helper
that handles cache lookup, executor submission, and post-success cache PUT.
Google response parsed with Gson — the gtx endpoint returns a nested array
[[['translated','original',null,null,1]],null,'en',...]; we concatenate the
first item of every segment for multi-sentence translations.
Also: skip the chat redraw when translation comes back equal to the
original — saves a useless replay for messages MyMemory/Google can't
translate (mostly proper nouns and short phrases). The toggle button stays
visible (it's baked into the formatted component at build time) but
clicking it is a visual no-op in this case, since both states show the
same text.
Two related changes so chat redraw preserves the entire visible chat and so
toggle behaviour is debuggable.
Full chat capture (the 'complete chat dump' the user asked for):
- Listener captures every chat-like event a player sees, not just
auto-translated player chat. New @Pulse handler for MessageReceiveEvent
saves the component (server messages, join/quit, vanilla broadcasts,
other plugins' messages) into TranslateModule's own per-player history.
- Existing onMessageSendEvent unconditionally records every CHAT-destination
send into history (translatedMessage may be null — recorded anyway).
- selfOriginatedComponents dedups against plugin-originated components so
a single chat line isn't saved twice (once on send, once on receive).
- onPlayerQuit clears the player's history to free memory.
Without this, sendUpdate's brute redraw rebuilds the visible chat using
only TranslateModule's history; anything not in that history (server
messages between chat lines) got pushed off-screen.
Toggle logging:
- TranslateModule.toggleOriginal logs every branch — player lookup,
history presence, entry search, hasTranslations check, showOriginal
flip, sendUpdate trigger. Distinct codepath for 'entry found but no
translation' vs 'entry not found', so we can tell from logs whether
server messages (no translation) are being clicked vs. real chat.
- sendUpdate logs receiver name, locale, history size and number of
empty newlines being spammed.
All log lines tagged [Toggle] for easy grep.
…atches
Provider chain
Config replaces the use_my_memory boolean with an ordered list:
format.translate.providers: [GOOGLE, MYMEMORY, DEEPL, YANDEX]
TranslationCacheService.translateAsync iterates providers in order, returning
the first response that's non-null/non-empty AND differs from the input text.
Any other outcome (HTTP 429, exception, integration disabled returning input
verbatim, etc.) moves on to the next provider. All-failed logs a WARNING and
the chain returns null.
Adds DEEPL/YANDEX support via integrationModuleProvider — they delegate to
existing IntegrationModule.deeplTranslate/yandexTranslate which already
handle disabled-state by returning the input text (we treat that as a fail
and continue the chain). No API keys → simply not in the chain.
Empty providers list logs a WARNING with a copy-paste config example and
auto-translate is disabled for that call.
In-flight de-duplication
TranslationCacheService now keeps a (source, target, text) → CompletableFuture
map. Concurrent calls for the same key share the same future instead of
hitting the API twice. The map entry is removed in whenComplete.
PrepareEvent dedupe (mitigation for double chat dispatch)
PulseAutoTranslateListener tracks (sender, locale, text) seen within the
last 1 second and skips duplicates. Each chat message on Paper/Purpur fires
the prepare flow twice with different UUIDs — root cause still under
investigation, but this kills the user-visible symptoms (2x API calls,
2x save into history, 2x sendUpdate replay duplicating chat lines).
…try; Yandex
IAM token
Cache fallback
TranslateModule.sendUpdate now pre-fills TranslatedMessage.translations
from the global TranslationCacheService cache before drawing. A message
whose initial async translation failed (429, network, etc.) will pick up
from the global TranslationCacheService cache before drawing. A message
whose initial async translation failed (429, network, etc.) will pick up
the cached value next time anything triggers a redraw — without firing
another API call.
Toggle as retry
When the player flips a message to 'show translation' and no usable
translation exists for their locale, toggleOriginal kicks off a fresh
translateAsync via the configured provider chain. On success the result
is stored in the per-message map and sendUpdate redraws. Debounced via
the existing in-flight dedup in TranslationCacheService — back-to-back
clicks don't spam the API.
Yandex IAM token
YandexIntegration.hook prefers a new 'iamToken' config field over the
deprecated 'token' (OAuth) field. Yandex Cloud is deprecating OAuth by
end of 2026 and recent translate-API requests already return
UNAUTHENTICATED for OAuth tokens.
To migrate: get an IAM token via 'yc iam create-token' (12h TTL) and
set it under integration.yandex.iamToken in config/integration.yml.
Keeping 'token' empty + 'iamToken' filled uses the modern path.
Existing 'token' (OAuth) still works as fallback for backward compat.
Replaces the per-player Map<UUID, List<TranslateHistoryMessage>> with a single time-ordered global list plus a viewer set on each entry. A message seen by N players is stored once with a N-sized viewer set instead of N duplicated entries with their own Component trees. Memory: on a 100-player server with 100 messages of history, drops from ~10000 entries to ~100. Component tree (the heaviest field) is shared. Per-player toggle state (showOriginal) was previously a field on each history entry — now lives in a separate map TranslateModule.playerOriginalToggles: Map<playerUUID, Set<messageUUID>>. A player's set contains the message UUIDs they've flipped to 'show original'. TranslateHistoryMessage.getDisplayComponent now takes showOriginal as a parameter (passed by sendUpdate from the player's toggle set) instead of reading it off the record. save() looks up by UUID and adds receiver to viewers if the entry already exists (one Component per message, no duplication on per-receiver save). sendUpdate() filters globalHistory by viewer membership before drawing. clearHistory() on player quit removes them from every viewer set and drops their toggle map; entries themselves stay for other players. FIFO trim of the oldest when globalHistory > historyLength().
Toggle button was disappearing after the global-history refactor: the first MessageSendEvent to land for a chat (often the sender themselves, same locale → no button by addTag's locale check) saved a buttonless Component into the entry. Subsequent SendEvents for receivers of other locales hit the existing-entry branch in save() and only added themselves to viewers, leaving the buttonless Component in place — so foreign-locale receivers never saw a toggle button. Fix: store a Map<locale, Component> per entry instead of a single shared Component. Each receiver's per-locale Component variant is recorded the first time someone of that locale saves the message. getDisplayComponent now picks the variant matching the receiver's locale. Memory stays bounded — ≤ N entries × M unique locales, not × players. On a typical server with 2-4 locales that's a 25-50x reduction vs the old per-player history without losing per-locale button visibility. Also: integration.yml now ships an iam_token field under yandex. The Java code already preferred it over OAuth in the previous commit, but the field wasn't surfaced in the default YAML, so users had to add it manually. Same change applied to the hytale variant.
The IAM path required users to regenerate a 12-hour token from yc CLI and
plug it into integration.yml — too much friction for a chat feature. User
prefers to defer Yandex entirely and rely on Google + MyMemory for now.
Reverts:
- Integration.Yandex record: drop iamToken field, back to (enable, token, folderId)
- YandexIntegration.hook: back to OAuth-only path
- integration.yml (minecraft, hytale): drop iam_token YAML field
Yandex still works for users with valid OAuth tokens; once Yandex Cloud
fully deprecates OAuth end of 2026, the integration will need to be revisited.
All [AutoTranslate] and [Toggle] info-level logs are now silent unless the
JVM was started with -Dflectonepulse.debug=true (the existing plugin-wide
debug flag from FLogger.DEBUG_ENABLED). On a production server they were
spamming several lines per chat message; now they only appear when the
operator explicitly opts in.
Implementation: added fLogger.debug(...) and fLogger.debug(fmt, args...)
to FLogger — internal guard on DEBUG_ENABLED, otherwise no-op. Replaced
every fLogger.info("[AutoTranslate]"...) and fLogger.info("[Toggle]"...)
call across TranslateModule, PulseAutoTranslateListener and
TranslationCacheService with fLogger.debug(...).
Warnings (provider errors, all-providers-failed, parse failures, etc.)
stay on fLogger.warning — real problems must surface even without the
debug flag.
…s opt-out
New config flag message.format.translate.auto (default true) splits the
feature into two operational modes, cleanly separating our async-translate
work from the original /translateto-on-click flow the author shipped.
auto: true (default)
Our path: PulseAutoTranslateListener runs async translation chains on
chat, saves to history, the ⇄ button issues /toggleoriginal which
flips between translation and original via brute-redraw replay.
auto: false
Author's classic path: the listener bails out at every entry point
(no async, no history, no toggle). The ⇄ button issues
/translateto <src> <dst> <uuid> which synchronously translates on
click and dispatches the translation as a new chat line — exactly
what shipped before the auto-translate feature.
YAML changes:
- message.format.translate: + 'auto: true' field
- localization.message.format.translate: + 'action_manual' field next
to 'action'; one is picked at runtime based on the auto flag
- en_us / ru_ru / hytale defaults populated with both templates
Code changes:
- Message.Format.Translate record: + Boolean auto
- Localization.*.Translate record: + String actionManual
- TranslateModule.addTag branches on config().auto():
true → simple <message>=UUID substitution, /toggleoriginal flow
false → author's <language>/<language>/<message> parsing,
/translateto flow (saveMessage UUID lookup intact)
- PulseAutoTranslateListener.isAutoMode() guard at every handler
entry; non-CHAT/non-FPlayer/destination checks come after
…it race Bug: when a translation was already cached, the receiver saw the ORIGINAL text on first display, and only on the *next* chat message did the prior one update to the translated version. Cause: translateAsync's cache-HIT path completes synchronously inside the caller thread → thenAccept fires immediately → replayForLocale → sendUpdate runs before MessageSendEvent has saved the entry to global history. Empty visible history → replay shows nothing useful → original goes out. Next chat message triggers another replay; by then history is populated and the fix retroactively shows up for the previous line. Fix: at MessageSendEvent, do a synchronous TranslationCacheService.get for (sourceLang, receiverLocale, originalText). On hit, apply the translation to event.message() directly via Component.replaceText and emit a withMessage override — the receiver sees the translation in the initial display, no replay needed. The save-to-history call uses the unmodified original Component so the toggle 'show original' branch keeps working — translated variants are rebuilt on demand from the cached translation + original component via replaceText in getDisplayComponent. Also: TranslateModule.getCachedTranslation exposed as a thin delegate over TranslationCacheService.get, so listener code doesn't reach into the cache service directly.
When translateAsync resolves synchronously via cache HIT, the .thenAccept callback runs inline on the PrepareEvent thread — before any MessageSendEvent has a chance to fire. The resulting replayForLocale call re-renders the stale per-receiver history (which does NOT yet contain this message), then SendEvent dispatches the actual new chat. The receiver ends up with the old visible chat lines duplicated, immediately followed by the new translated line — visually 'hello hello' or 'hello привет hello'. Fix: do a synchronous cache lookup at the top of translateToAllLocales, per target locale. On hit, populate TranslatedMessage.translations directly and return — no async, no replay. The cache-hit path for the current message is handled by the existing sync check in PulseAutoTranslateListener.onMessageSendEvent which applies replaceText to the outgoing Component. Replay remains useful only for the cache-MISS path, where older history entries need a redraw once the chain resolves the translation.
When the same text is sent in rapid succession (< 1s apart), the listener's 1-second dedup window suppresses every PrepareEvent after the first. The chat messages themselves still flow through MessageSendEvent — but with no preparedTranslations entry, onMessageSendEvent used to bail out early and the receiver saw the original text instead of the cached translation. User observed this as 'hello привет hello hello привет' patterns: first send translates, immediate repeats fall back to original. Fix: drop the early bail. Always derive sourceLang — from the prepared TranslatedMessage when available, otherwise from sender.getSetting(LOCALE) — and do a synchronous cache lookup against TranslationCacheService. Cache HIT → apply via Component.replaceText and withMessage exactly as the prepared path does. History save still gates on translatedMessage != null: dedup-skipped messages get the visual translation but don't enter globalHistory, keeping the buffer free of identical spam. Toggle on those messages no-ops in /toggleoriginal (logged as 'no translations') — acceptable since the toggle UI is for ad-hoc clicks, not rapid-fire spam.
… diagnosis
Adds per-entry debug logging through TranslateModule's history service so
the 'clicking lower duplicate toggles upper line' bug can be diagnosed
from logs alone.
[History.save] — for every save call:
uuid, receiver, locale, originalText, hasTranslatedMessage flag,
mode (CREATE vs UPDATE existing entry), new viewers count, global size.
Tells us whether two visually-duplicate chat lines map to one or two
separate history entries.
[History.addTag] — for every translation tag resolution:
whether button is hidden (sender non-FPlayer / same locale) and why,
which UUID gets embedded into the /toggleoriginal action for each
receiver. Confirms each visible button on a chat line carries its
own message UUID.
[Toggle] sendUpdate per-entry — during chat redraw:
index, total visibleEntries, each entry's uuid, text, current
showOriginal flag, translation string for the receiver's locale.
Shows exactly which entries the receiver sees redrawn and in what
state — so a 'lower toggle changed upper' observation can be cross-
referenced against which UUIDs are actually being painted.
Gated through fLogger.debug — silent without -Dflectonepulse.debug=true.
MessageReceiveEvent fires for every chat packet a player receives — including packet echoes of the plugin's own chat dispatches that the isCached/cachedComponents filter misses (different Component reference identity due to internal rebuild on the receive path). These echoes show up in the log as save() calls with text='' and bloat the global history: a single chat exchange ends up with 2-3x the entries needed, half of them blank, all printed during brute redraw. Fix: render the incoming component to plain text via PlainTextComponentSerializer and skip the save when the result is blank. The visible chat doesn't depend on this listener's records, the history is purely for replay reconstruction, and replay rendering of an empty-component line just emits an empty chat row anyway. Reduces 'visibleEntries' in toggle redraws by roughly half on a typical conversation, which directly addresses the user-reported 'why are there so many duplicates after I click show original' complaint.
Root cause of phantom 'text=' history entries appearing after every chat:
onMessageSendEvent saved originalComponent into selfOriginatedComponents
(via save's needToCache=true). But when a cached translation was applied,
event.withMessage(translatedComponent) made the outgoing packet carry
translatedComponent — a different Component instance. The packet's
PacketSendEvent → MessageReceiveEvent fired with translatedComponent, but
isCached() compares by reference and only found originalComponent → dedup
silently failed → onMessageReceiveEvent saved a second history entry with
empty originalText.
empty originalText.
Fix:
- New TranslateModule.markSelfOriginated(Component) — registers a
Component for ReceiveEvent dedup separately from save().
- onMessageSendEvent now calls save(...) with needToCache=false (history
stores originalComponent for the showOriginal toggle path) and then
markSelfOriginated(event.message()) AFTER any withMessage(...) — so
whatever variant the packet actually carries is the one we register.
For the cache-miss path (no withMessage applied) event.message() ==
originalComponent, so behavior is unchanged.
Identified by analysing PulseDeleteListener: the author's module doesn't
modify event.message() between save() and the packet send, so its
cached/sent components are always identical and dedup just works. Our
translation-applying listener needed the extra step.
…p-skipped and system messages
Previously the listener bailed out of save() whenever preparedTranslations
didn't contain a TranslatedMessage for the current messageUUID. Two real-world
chat events have a null TranslatedMessage and were silently dropped from the
history:
1. Dedup-skipped rapid repeats. PrepareEvent's 1-second per-(sender,text)
dedup cache prevents the second through Nth same-content chat from
populating preparedTranslations. The SendEvent still fires for each
receiver, but with my previous code skipped save() because
translatedMessage was null. Net effect: a player who typed 'hello' six
times in two seconds got one history entry; replay/redraw on toggle
showed only that one line, losing five visible chat events.
2. System messages — join, quit, /give output, plugin announcements that
dispatch through messageDispatcher with a non-FPlayer sender. PrepareEvent
bails out at the FPlayer instanceof check, no TranslatedMessage is ever
created, and the SendEvent listener used to skip save(). Chat redraw
pushed these off-screen with the brute-newline spam.
Fix: save() runs unconditionally now. For the dedup-skipped path where the
cache HIT for this receiver's locale, the listener synthesizes a
TranslatedMessage on the fly (originalLang + receiverLocale entries
populated) so toggle works on those entries too. System messages save with
translatedMessage=null — they show as original in any redraw and are not
toggleable, which is correct since there's nothing to switch between.
needToCache stays false at the save call: the outgoing packet's dedup
component is registered separately via markSelfOriginated(event.message())
right after, capturing the result of any prior withMessage(translatedComponent)
substitution. ReceiveEvent on the echo-back sees the exact reference and
skips, no duplicate entries.
Bug surfaced as half the history entries having translationForLocale=<none> in sendUpdate logs even though the cache had a valid translation. Clicking toggle on those entries logged 'has no translations (server/system message)' and did nothing visually — user thought toggle was 'lagging'. Root cause: save() runs once per receiver, and per-receiver behavior in PulseAutoTranslateListener.onMessageSendEvent diverges based on whether receiverLocale == sourceLang. For the SAME-locale receiver (the sender's own view) the listener never enters the cache-lookup branch and passes a null TranslatedMessage to save(). For the DIFFERENT-locale receiver it synthesizes one. Save order isn't guaranteed: when the same-locale save runs first it CREATEs the entry with a null TM; the subsequent UPDATE path silently discarded the incoming non-null TM. Half the entries stayed un-toggleable. Fix: in the UPDATE branch, if existing.translatedMessage is null but the incoming one isn't, replace the entry via withTranslatedMessage (records are immutable; list.set keeps the global order intact). If both are non-null the incoming translations map is merged via putAll — keeps any per-locale entries the existing one already has and adds new ones. This matches the user's intended architecture: the global history *holds* the translations alongside originalText and viewers, rather than relying on the global cache as the sole source of truth at render time.
|
Привет! Я реализовал всё, что планировал: сообщения теперь переводятся полностью автоматически, и переключение между оригиналом и переводом изменяет только текст сообщения. Также реализовал задумку с сохранением общей истории полученных сообщений: теперь к каждому сообщению присваиваются получатели, благодаря чему история чата восстанавливается для каждого игрока индивидуально. Можешь проверить. Единственный нюанс текст над головой пока не переводятся. Это отдельный модуль, и для его асинхронного перевода придётся сильно изменять логику модуля. |
TheFaser
left a comment
There was a problem hiding this comment.
Ещё в целом старайся делать меньше комментариев и java doc, здесь это не очень уместно, потому что нет стандарта сейчас у меня, который я бы придерживался для обычных модулей. Максимум можешь оставить комменты для понимания происходящего внутри методов, но не после каждого слова
| fLogger.debug("[AutoTranslate] PrepareEvent: skip uuid=%s — duplicate of recent message (sender=%s text='%s')", | ||
| messageUUID, sender.name(), message); |
There was a problem hiding this comment.
я не думаю, что debug имеет смысл, слишком много спама в консоль, даже учитывая, что он только при включённом флаге. Я не реализовывал систему дебага впринципе, потому что не вижу в этом большого смысла
There was a problem hiding this comment.
я просто отстраивал систему на дебаге ну если не нужно то могу убрать или сократить
There was a problem hiding this comment.
я просто отстраивал систему на дебаге ну если не нужно то могу убрать или сократить
Сейчас лучше убрать дебаг полностью, над ним нужно думать отдельно, как о глобальной системе в проекте
| private final List<TranslateHistoryMessage> globalHistory = new java.util.ArrayList<>(); | ||
|
|
||
| /** | ||
| * Per-player toggle state: which message UUIDs that player has flipped to | ||
| * "show original". Stored separately from the history entries because it's | ||
| * per-player; the entries themselves are shared. | ||
| */ | ||
| private final Map<UUID, Set<UUID>> playerOriginalToggles = new ConcurrentHashMap<>(); | ||
|
|
||
| /** Components originated by FlectonePulse itself — dedup against MessageReceiveEvent. */ | ||
| private final List<Component> selfOriginatedComponents = new CopyOnWriteArrayList<>(); |
There was a problem hiding this comment.
зачем это здесь? Тебе нужна только 1 мапа, где будет ключом UUID - receiver, а значением сообщение готовое, тут ты слишном намудрил
| String urlString = "https://api.mymemory.translated.net/get?q=" + encodedText | ||
| + "&langpair=" + normalizedSource + "%7C" + normalizedTarget; |
| URI uri = new URI(urlString); | ||
| HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection(); | ||
| connection.setRequestMethod("GET"); | ||
| connection.setRequestProperty("User-Agent", "FlectonePulse/1.9.4"); |
There was a problem hiding this comment.
есть WebUtil, там указан User Agent
| duration: 24 | ||
| time_unit: "HOURS" | ||
| size: 50000 |
There was a problem hiding this comment.
24 часа? это слишком много. Кэш создан чтобы удаляться через время и не занимать память просто так, а ты по сути из кэша превратил его в обычную мапу, 1 час максимум
| # auto: true → async pre-translation + /toggleoriginal button | ||
| # auto: false → author's classic /translateto click-to-translate mode | ||
| auto: true | ||
| providers: | ||
| - MYMEMORY | ||
| - DEEPL | ||
| - YANDEX |
There was a problem hiding this comment.
зачем? это указывается в команде tranlateto, для чего это дублирование?
| action: " <click:run_command:\"/toggleoriginal <message>\"><hover:show_text:\"<fcolor:2>Show original/translation\"><fcolor:1><sprite_or:⇄:gui:icon/language>" | ||
| # Used when message.format.translate.auto: false (classic /translateto mode) | ||
| action_manual: " <click:run_command:\"/translateto <language> <language> <message>\"><hover:show_text:\"<fcolor:2>Translate message\"><fcolor:1><sprite_or:⇄:gui:icon/language>" |
There was a problem hiding this comment.
над названием параметров нужно точно подумать, должно быть понятно что это за формат
| synchronized (globalHistory) { | ||
| for (TranslateHistoryMessage e : globalHistory) { | ||
| if (e.uuid().equals(messageUUID)) { | ||
| entry = e; | ||
| break; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
как я написал выше, тебе нужна только одна мапа, без этого листа
|
|
||
| /** True when message.format.translate.auto is enabled (default). */ | ||
| private boolean isAutoMode() { | ||
| return !Boolean.FALSE.equals(translateModule.config().auto()); |
There was a problem hiding this comment.
просто проверяй translateModule.config().auto() без всяких Boolean.FALSE/TRUE, это нейронка и IDEA думают, что они могут быть null, но нет, они не могут быть такими, иначе плагин не включится
Summary
This PR implements automatic message translation based on player's client language settings. Messages are now automatically translated to each player's locale, with an option to toggle between original and
translation.
Changes
/toggleoriginalto switch between original and translated messageHow it works
Configuration
Requires
language.by-player: truein config.yml and translation API setup (Google/Yandex/DeepL).Testing
Tested with multiple players using different client languages. Translation and toggle functionality working as expected.
Note
Paper/Bukkit module has pre-existing compilation errors (missing Paper-specific classes). Core module compiles successfully. Fabric, BungeeCord, and Velocity versions work perfectly.