feat(translate): Add automatic message translation to player language#335
feat(translate): Add automatic message translation to player language#335SolverNA wants to merge 51 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.
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, но нет, они не могут быть такими, иначе плагин не включится
…ialService.getSetting, MessagePipeline.resolver/build, CommandRegistry)
… отправителя и индекс/число чанков
…уемый сплиттер splitText
… обновления перевода
…ода и рефлоу стека
…ubble для пузырьков
…е-текстовых узлах
…ереотрисовку (тёплый/холодный путь)
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.