Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
ccccadc
feat(translate): add automatic message translation to player language
SolverNA May 11, 2026
01ae38d
feat(translate): add MyMemory API cache and async translation
SolverNA May 11, 2026
c054317
fix(translate): fix 5 bugs in auto-translation system
SolverNA May 13, 2026
37592a1
refactor(translate): remove dead uuid field and hide button for singl…
SolverNA May 13, 2026
50b1e17
fix(translate): Fix replacing formatted messages with plain text
SolverNA May 14, 2026
acf460d
feat(translate): Added detailed logging for the autotranslation pipel…
SolverNA May 14, 2026
fedfc9c
refactor: extract ChatHistoryService from DeleteModule
SolverNA May 14, 2026
c8d8a14
revert: restore author's modules to pre-translate state
SolverNA May 14, 2026
d33156f
feat(translate): self-contained auto-translate with own chat history
SolverNA May 14, 2026
49bafeb
fix(translate): restore TRANSLATION_CACHE setting in default config.yml
SolverNA May 14, 2026
2648a90
fix(translate): decode MyMemory response with Gson — fixes Cyrillic s…
SolverNA May 14, 2026
b1fa77f
fix(translate): preserve formatting on translation replay + decode Cy…
SolverNA May 14, 2026
12e7af9
feat(translate): Google Translate as default provider, MyMemory opt-i…
SolverNA May 14, 2026
755a23c
feat(translate): full chat capture in history + detailed toggle logging
SolverNA May 14, 2026
0c20ebf
feat(translate): provider chain with fallback + dedupe duplicate disp…
SolverNA May 14, 2026
583454e
feat(translate): cache fallback in replay + toggle-triggered chain re…
SolverNA May 14, 2026
e32aa2b
refactor(translate): global shared chat history with viewer sets
SolverNA May 14, 2026
cfcf6a9
fix(translate): per-locale Component variants + iam_token YAML field
SolverNA May 14, 2026
ccd9b04
revert: roll back Yandex IAM token support
SolverNA May 14, 2026
ae36876
chore(translate): gate verbose translate logs behind flectonepulse.debug
SolverNA May 14, 2026
ce38ca2
feat(translate): auto/manual mode flag — keep author's classic path a…
SolverNA May 14, 2026
7acefc2
fix(translate): sync cache lookup at MessageSendEvent removes cache-h…
SolverNA May 14, 2026
3560034
fix(translate): skip replay on cache hit, fixes 'ghost' duplicate lines
SolverNA May 14, 2026
0275a13
fix(translate): apply cached translation to dedup-skipped messages too
SolverNA May 15, 2026
003323c
chore(translate): verbose [History.*] logs for save/sendUpdate/addTag…
SolverNA May 15, 2026
f21b6ba
fix(translate): skip empty-component receive events from history
SolverNA May 15, 2026
9be1192
fix(translate): cache the SENT component for dedup, not the original
SolverNA May 15, 2026
819b969
fix(translate): save every chat SendEvent to history — including dedu…
SolverNA May 15, 2026
db0a6f5
fix(translate): merge translatedMessage on save() UPDATE path
SolverNA May 15, 2026
d3ccba2
feat(translate): очистка истории чата при выходе игрока (clearHistory…
SolverNA Jun 13, 2026
9ec0c9d
Merge upstream/master: адаптация translate-системы под новые API (Soc…
SolverNA Jun 13, 2026
0fc307a
refactor(translate): убрать debug-логирование (сохранён апстримный DE…
SolverNA Jun 13, 2026
991617f
refactor(translate): уменьшить TTL кэша переводов с 24ч до 1ч
SolverNA Jun 13, 2026
0296ce9
refactor(translate): вынести URL переводчиков в константы
SolverNA Jun 13, 2026
c440d50
refactor(translate): парсить ответы переводчиков через gson вместо ру…
SolverNA Jun 13, 2026
2376200
feat(translate): сохранять регион языка (zh-CN/zh-TW/pt-BR/pt-PT) при…
SolverNA Jun 13, 2026
e7dd313
refactor(translate): использовать единый WebUtil.USER_AGENT вместо ха…
SolverNA Jun 13, 2026
4932bed
refactor(translate): убрать избыточный javadoc и комментарии
SolverNA Jun 13, 2026
fe18dac
refactor(translate): убрать инлайн FQN java.util в пользу импортов
SolverNA Jun 13, 2026
d49bc4e
feat(bubble): добавить в модель Bubble полный текст сообщения, локаль…
SolverNA Jun 13, 2026
29b6798
feat(bubble): добавить переключатель bubble.translate в конфиг (по ум…
SolverNA Jun 13, 2026
e766272
feat(bubble): прогрев кэша перевода по локалям зрителей и переиспольз…
SolverNA Jun 13, 2026
c71589c
feat(bubble): сделать текст сущности пузырька изменяемым для in-place…
SolverNA Jun 13, 2026
ec82792
feat(bubble): пер-зрительский авто-перевод пузырьков с re-split перев…
SolverNA Jun 13, 2026
2705cb6
fix(bubble): исправить перевёрнутый порядок чанков в стеке пузырька над
SolverNA Jun 14, 2026
e3f1643
feat(translate): добавить пер-получательский async-перевод для приват…
SolverNA Jun 14, 2026
390ccee
fix(translate): подключить копию получателя ЛС к автопереводу на холо…
SolverNA Jun 14, 2026
7bdeb7f
feat(translate): единый тумблер auto (чат+ЛС) и отдельный translate.b…
SolverNA Jun 15, 2026
a82f450
feat(translate): кросс-узловая замена текста сообщения с advance на н…
SolverNA Jun 15, 2026
5622f4f
feat(translate): пер-получательский авто-перевод ЛС через историю и п…
SolverNA Jun 15, 2026
79eeab3
feat(translate): добавить кнопку «показать оригинал» для копии получа…
SolverNA Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1244,7 +1244,7 @@ public record QuestionAnswer(
@With
@Builder(toBuilder = true)
@Jacksonized
public record Translate(String action) implements LocalizationSetting {
public record Translate(String action, String actionManual) implements LocalizationSetting {
}
}

Expand Down
11 changes: 10 additions & 1 deletion core/src/main/java/net/flectone/pulse/config/Message.java
Original file line number Diff line number Diff line change
Expand Up @@ -578,7 +578,16 @@ public record DeprecatedScoreboard(
@With
@Builder(toBuilder = true)
@Jacksonized
public record Translate(Boolean enable) implements EnableSetting {
public record Translate(
Boolean enable,
// Single master toggle for chat AND private-message auto-translate.
// Both are driven together (key: message.format.translate.auto).
Boolean auto,
// Independent bubble auto-translate toggle (key: message.format.translate.bubble).
Boolean bubble,
Boolean clearHistoryOnQuit,
List<String> providers
) implements EnableSetting {
}

@With
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import net.flectone.pulse.module.command.symbol.SymbolModule;
import net.flectone.pulse.module.command.tell.TellModule;
import net.flectone.pulse.module.command.tictactoe.TictactoeModule;
import net.flectone.pulse.module.command.toggleoriginal.ToggleOriginalModule;
import net.flectone.pulse.module.command.toponline.ToponlineModule;
import net.flectone.pulse.module.command.translateto.TranslatetoModule;
import net.flectone.pulse.module.command.try_.TryModule;
Expand Down Expand Up @@ -106,6 +107,7 @@ public class CommandModule implements ModuleSimple {
SymbolModule.class,
TellModule.class,
TictactoeModule.class,
ToggleOriginalModule.class,
ToponlineModule.class,
TranslatetoModule.class,
TryModule.class,
Expand Down
Comment thread
SolverNA marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package net.flectone.pulse.module.command.toggleoriginal;

import com.google.inject.Inject;
import com.google.inject.Singleton;
import lombok.RequiredArgsConstructor;
import net.flectone.pulse.config.Command;
import net.flectone.pulse.config.Localization;
import net.flectone.pulse.config.Permission;
import net.flectone.pulse.model.entity.FPlayer;
import net.flectone.pulse.module.ModuleCommand;
import net.flectone.pulse.module.message.format.translate.TranslateModule;
import net.flectone.pulse.platform.controller.ModuleController;
import net.flectone.pulse.platform.provider.CommandParserProvider;
import net.flectone.pulse.platform.registry.CommandRegistry;
import net.flectone.pulse.service.SocialService;
import net.flectone.pulse.util.constant.ModuleName;
import net.flectone.pulse.util.constant.SettingText;
import net.flectone.pulse.util.file.FileFacade;
import org.incendo.cloud.context.CommandContext;

import java.util.UUID;

// /toggleoriginal <message-uuid> — flips a chat message between translated and original.
// Reuses the Deletemessage command config (cooldown/aliases/permission) since there's no
// dedicated config record for this command.
@Singleton
@RequiredArgsConstructor(onConstructor = @__(@Inject))
public class ToggleOriginalModule implements ModuleCommand<Localization.Command.Deletemessage> {

private final FileFacade fileFacade;
private final CommandParserProvider commandParserProvider;
private final TranslateModule translateModule;
private final ModuleController moduleController;
private final CommandRegistry commandRegistry;
private final SocialService socialService;

@Override
public void onEnable() {
commandRegistry.registerCommand(manager -> manager
.commandBuilder("toggleoriginal")
.required("message", commandParserProvider.singleMessageParser())
.handler(this)
);
}

@Override
public void onDisable() {
}

@Override
public void execute(FPlayer fPlayer, CommandContext<FPlayer> commandContext) {
if (moduleController.isDisabledFor(this, fPlayer)) return;

String messageUuidString = commandContext.get("message");

try {
UUID messageUuid = UUID.fromString(messageUuidString);
translateModule.toggleOriginal(fPlayer, messageUuid);
} catch (IllegalArgumentException _) {
// Invalid UUID, ignore
}
}

@Override
public ModuleName name() {
return ModuleName.COMMAND_TOGGLEORIGINAL;
}

@Override
public Command.Deletemessage config() {
return fileFacade.command().deletemessage();
}

@Override
public Permission.Command.Deletemessage permission() {
return fileFacade.permission().command().deletemessage();
}

@Override
public Localization.Command.Deletemessage localization(FPlayer fPlayer) {
return fileFacade.localization(socialService.getSetting(fPlayer, SettingText.LOCALE)).command().deletemessage();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ public class Bubble {

private final String rawMessage;

// Full, un-split message text in the sender's language. Used by the renderer to
// translate the whole message once per viewer-locale and re-split the translation.
private final String fullMessage;

// Sender's locale (source language for translation). May be null.
private final String senderLocale;

// Index of this chunk within its message (chunks of one message share the same id),
// and the total number of original chunks. The renderer renders the whole re-split
// stack on chunkIndex == 0 and treats chunkIndex > 0 as a no-op.
@lombok.Builder.Default
private final int chunkIndex = 0;

@lombok.Builder.Default
private final int chunkCount = 1;

private final long duration;

private final float elevation;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
import net.flectone.pulse.module.message.bubble.model.ModernBubble;
import net.flectone.pulse.module.message.bubble.render.BubbleRender;
import net.flectone.pulse.processing.converter.ColorConverter;
import net.flectone.pulse.service.SocialService;
import net.flectone.pulse.service.TranslationCacheService;
import net.flectone.pulse.util.constant.MessageFlag;
import net.flectone.pulse.util.constant.SettingText;
import net.flectone.pulse.util.file.FileFacade;
import net.flectone.pulse.util.generator.RandomGenerator;
import org.jspecify.annotations.NonNull;
Expand All @@ -37,6 +40,8 @@ public class BubbleService {
private final TaskScheduler taskScheduler;
private final RandomGenerator randomUtil;
private final MessagePipeline messagePipeline;
private final SocialService socialService;
private final TranslationCacheService translationCacheService;

public void startTicker() {
taskScheduler.runPlayerAsyncTimer(fPlayer -> {
Expand All @@ -56,21 +61,56 @@ public void addMessage(@NonNull FPlayer sender, @NonNull String message, List<FP
);


List<Bubble> bubbles = splitMessageToBubbles(
sender,
messagePipeline.buildPlain(MessageContext.builder()
.sender(sender)
.message(message)
.flags(
new MessageFlag[]{MessageFlag.PLAYER_MESSAGE, MessageFlag.MENTION_MODULE, MessageFlag.INTERACTIVE_CHAT_COMPAT, MessageFlag.QUESTIONANSWER_MODULE, MessageFlag.ITEM_DETECTION, MessageFlag.OBJECT_SPRITE_PROCESSING, MessageFlag.OBJECT_PLAYER_HEAD_PROCESSING, MessageFlag.OBJECT_TEXTURE_PROCESSING, MessageFlag.REMOVE_DISABLED_TAGS, MessageFlag.VIOLATION_PROCESSING, MessageFlag.URL_PROCESSING},
new boolean[]{true, false, false, false, false, false, false, false, false, false, false}
)
.build()
),
receivers
String plainMessage = messagePipeline.buildPlain(MessageContext.builder()
.sender(sender)
.message(message)
.flags(
new MessageFlag[]{MessageFlag.PLAYER_MESSAGE, MessageFlag.MENTION_MODULE, MessageFlag.INTERACTIVE_CHAT_COMPAT, MessageFlag.QUESTIONANSWER_MODULE, MessageFlag.ITEM_DETECTION, MessageFlag.OBJECT_SPRITE_PROCESSING, MessageFlag.OBJECT_PLAYER_HEAD_PROCESSING, MessageFlag.OBJECT_TEXTURE_PROCESSING, MessageFlag.REMOVE_DISABLED_TAGS, MessageFlag.VIOLATION_PROCESSING, MessageFlag.URL_PROCESSING},
new boolean[]{true, false, false, false, false, false, false, false, false, false, false}
)
.build()
);

List<Bubble> bubbles = splitMessageToBubbles(sender, plainMessage, receivers);

state.waitingQueue.addAll(bubbles);

warmTranslationCache(sender, plainMessage, receivers);
}

// Fire-and-forget cache warming: for every distinct online viewer locale that differs
// from the sender's, kick off a translation of the WHOLE message so the renderer can
// pick it up synchronously when the bubble surfaces. Gated on chat auto-translate.
private void warmTranslationCache(@NonNull FPlayer sender, @NonNull String fullMessage, List<FPlayer> receivers) {
if (!isBubbleTranslateEnabled()) return;
if (receivers == null || receivers.isEmpty()) return;

String senderLocale = socialService.getSetting(sender, SettingText.LOCALE);
if (senderLocale == null) return;

List<String> providers = fileFacade.message().format().translate().providers();
if (providers == null || providers.isEmpty()) return;

Set<String> targetLocales = new HashSet<>();
for (FPlayer receiver : receivers) {
if (receiver == null || receiver.isUnknown()) continue;
String viewerLocale = socialService.getSetting(receiver, SettingText.LOCALE);
if (viewerLocale == null || viewerLocale.equals(senderLocale)) continue;
targetLocales.add(viewerLocale);
}

for (String targetLocale : targetLocales) {
translationCacheService.translateAsync(senderLocale, targetLocale, fullMessage, providers);
}
}

// Bubble auto-translate runs only when the translate module is enabled AND the
// bubble auto-translate toggle (message.format.translate.bubble) is on (default true).
private boolean isBubbleTranslateEnabled() {
Message.Format.Translate translate = fileFacade.message().format().translate();
boolean enabled = Boolean.TRUE.equals(translate.enable());
boolean bubbleToggle = !Boolean.FALSE.equals(translate.bubble());
return enabled && bubbleToggle;
}

private List<Bubble> splitMessageToBubbles(@NonNull FPlayer sender, @NonNull String message, List<FPlayer> receivers) {
Expand Down Expand Up @@ -102,27 +142,39 @@ private List<Bubble> splitMessageToBubbles(@NonNull FPlayer sender, @NonNull Str
int maxLength = config.maxLength();
int maxCount = config.maxCount();

if (message.length() <= maxLength) {
return List.of(buildBubble(
id, sender, message, duration, elevation, interactionHeight,
String senderLocale = socialService.getSetting(sender, SettingText.LOCALE);

List<String> chunks = splitText(message, maxLength, maxCount, hintBufferLength, wordBreakHint);

List<Bubble> bubbles = new ObjectArrayList<>();
for (int i = 0; i < chunks.size(); i++) {
bubbles.add(buildBubble(
id, sender, chunks.get(i), message, senderLocale, i, chunks.size(),
duration, elevation, interactionHeight,
useInteractionRiding, useModernBubble, hasShadow, seeThrough, background,
animationTime, scale, billboard, receivers
));
}

List<Bubble> bubbles = new ObjectArrayList<>();
return List.copyOf(bubbles);
}

// Symbol-level splitter shared by the original message and by re-splitting a
// translation at render time so both produce identically-shaped chunks.
public static List<String> splitText(String message, int maxLength, int maxCount, int hintBufferLength, String wordBreakHint) {
if (message.length() <= maxLength) {
return List.of(message);
}

List<String> chunks = new ObjectArrayList<>();
int start = 0;

while (start < message.length() && bubbles.size() < maxCount) {
while (start < message.length() && chunks.size() < maxCount) {
int end = Math.min(start + maxLength, message.length());

// the last
if (end == message.length()) {
bubbles.add(buildBubble(
id, sender, message.substring(start).trim(), duration, elevation, interactionHeight,
useInteractionRiding, useModernBubble, hasShadow, seeThrough, background,
animationTime, scale, billboard, receivers
));
chunks.add(message.substring(start).trim());
break;
}

Expand All @@ -147,19 +199,15 @@ private List<Bubble> splitMessageToBubbles(@NonNull FPlayer sender, @NonNull Str
nextStart = end;
}

bubbles.add(buildBubble(
id, sender, chunk, duration, elevation, interactionHeight,
useInteractionRiding, useModernBubble, hasShadow, seeThrough, background,
animationTime, scale, billboard, receivers
));

chunks.add(chunk);
start = nextStart;
}

return List.copyOf(bubbles);
return List.copyOf(chunks);
}

private Bubble buildBubble(int id, FPlayer sender, String message, long duration, float elevation, float interactionHeight,
private Bubble buildBubble(int id, FPlayer sender, String message, String fullMessage, String senderLocale,
int chunkIndex, int chunkCount, long duration, float elevation, float interactionHeight,
boolean interactionRiding, boolean useModern, boolean hasShadow, boolean seeThrough, int background,
int animationTime, float scale, BubbleModule.Billboard billboard, List<FPlayer> receivers) {
Bubble.BubbleBuilder<?, ?> builder = useModern
Expand All @@ -176,6 +224,10 @@ private Bubble buildBubble(int id, FPlayer sender, String message, long duration
.id(id)
.sender(sender)
.rawMessage(message)
.fullMessage(fullMessage)
.senderLocale(senderLocale)
.chunkIndex(chunkIndex)
.chunkCount(chunkCount)
.duration(duration)
.elevation(elevation)
.interactionHeight(interactionHeight)
Expand Down Expand Up @@ -251,7 +303,7 @@ private long calculateDuration(String message) {
return (long) (((countWords + config.handicapChars()) / config.readSpeed()) * 60) * 1000L;
}

private boolean isNotLetter(char symbol) {
private static boolean isNotLetter(char symbol) {
if (Character.isLetter(symbol)) return false;
if (Character.isSpaceChar(symbol)) return true;

Expand Down
Loading