diff --git a/__tests__/lib/runtime-fallback.test.ts b/__tests__/lib/runtime-fallback.test.ts index cccbd07f..03814571 100644 --- a/__tests__/lib/runtime-fallback.test.ts +++ b/__tests__/lib/runtime-fallback.test.ts @@ -92,6 +92,27 @@ describe('runtimePlanMessageKey', () => { expect(runtimePlanMessageKey({ action: 'none' })).toBe(''); }); + it('a gioco chiuso non dichiara impossibile ciò che è solo fallito', () => { + // "Questo gioco non si lascia tradurre nei file" è vero se il motore non + // espone testo, ed è una bugia se è appena caduto Ollama. + const plan = { action: 'await-launch', processName: 'Game.exe' } as const; + expect(runtimePlanMessageKey(plan, 'engine-unsupported')).toBe( + 'gameDetail.runtimeFallbackAwaitLaunch', + ); + expect(runtimePlanMessageKey(plan, 'run-failed')).toBe( + 'gameDetail.runtimeFallbackAfterFailure', + ); + }); + + it('la causa non cambia il messaggio quando si inietta subito', () => { + // Il gioco è aperto: quello che succede è identico, e il messaggio descrive + // l'azione, non la diagnosi. + const plan = { action: 'inject', processName: 'Game.exe' } as const; + expect(runtimePlanMessageKey(plan, 'engine-unsupported')).toBe( + runtimePlanMessageKey(plan, 'run-failed'), + ); + }); + it('distingue il blocco per motivo', () => { expect(runtimePlanMessageKey({ action: 'unavailable', blocker: 'anti-cheat' })).toBe( 'gameDetail.runtimeFallbackBlocked.anti-cheat', @@ -146,6 +167,26 @@ describe('buildRunReport', () => { }); expect(r.nextStepKey).toBe('gameDetail.runtimeFallbackAwaitLaunch'); }); + + it('propaga la causa fino alla chiave del messaggio', () => { + const r = buildRunReport({ + gameTitle: 'Gioco', + staticOutcome: 'failure', + plan: { action: 'await-launch', processName: 'Game.exe' }, + cause: 'run-failed', + }); + expect(r.cause).toBe('run-failed'); + expect(r.nextStepKey).toBe('gameDetail.runtimeFallbackAfterFailure'); + }); + + it('senza causa esplicita assume motore non supportato', () => { + const r = buildRunReport({ + gameTitle: 'Gioco', + staticOutcome: 'failure', + plan: { action: 'await-launch', processName: 'Game.exe' }, + }); + expect(r.cause).toBe('engine-unsupported'); + }); }); describe('summarizeRunReport', () => { diff --git a/components/game-detail-client.tsx b/components/game-detail-client.tsx index 8c50d535..8c57d273 100644 --- a/components/game-detail-client.tsx +++ b/components/game-detail-client.tsx @@ -52,6 +52,7 @@ import { buildRunReport, summarizeRunReport, type RunReport, + type FallbackCause, } from '@/lib/translation/runtime-fallback'; import { TARGET_LANGUAGES as CANONICAL_TARGET_LANGUAGES } from '@/lib/translation/target-languages'; import { LANG_TO_CODE } from '@/lib/translation/language-mappings'; @@ -2061,6 +2062,7 @@ export default function GameDetailPage() { const tryRuntimeFallback = async ( staticOutcome: PatchOutcome, counts: { injected?: number | null; total?: number | null }, + cause: FallbackCause = 'engine-unsupported', ): Promise => { if (!game?.installPath) return null; @@ -2105,6 +2107,7 @@ export default function GameDetailPage() { stringsInjected: counts.injected ?? null, stringsTotal: counts.total ?? null, plan, + cause, }); // Il report per gioco finisce nella cronologia: dice cosa è entrato nel @@ -2216,6 +2219,7 @@ export default function GameDetailPage() { } catch (e) { await dTracker.fail(e); toast.error(t('gameDetail.dr1Error'), { id: toastId, description: String(e).slice(0, 180) }); + void tryRuntimeFallback('failure', {}, 'run-failed'); } finally { setAutoTranslateBusy(false); setAutoTranslateProgress(''); @@ -2265,6 +2269,7 @@ export default function GameDetailPage() { } catch (e) { await hTracker.fail(e); toast.error(t('gameDetail.errHendrix'), { id: toastId, description: String(e) }); + void tryRuntimeFallback('failure', {}, 'run-failed'); } finally { setAutoTranslateBusy(false); setAutoTranslateProgress(''); @@ -2397,6 +2402,7 @@ export default function GameDetailPage() { ); await rpTracker.fail(e); toast.error(t('gameDetail.errRenpy'), { description: String(e) }); + void tryRuntimeFallback('failure', {}, 'run-failed'); } finally { setAutoTranslateBusy(false); setAutoTranslateProgress(''); @@ -2540,6 +2546,7 @@ export default function GameDetailPage() { await tray.notifyTranslationFailed(game.name || game.title || 'Gioco', String(e)); } catch { /* tray non disponibile */ } toast.error(t('heroJob.visError').replace('{hint}', hint), { description: String(e) }); + void tryRuntimeFallback('failure', {}, 'run-failed'); } finally { VIS_RUNNING.delete(game.installPath); setAutoTranslateBusy(false); @@ -2640,6 +2647,7 @@ export default function GameDetailPage() { setAutoTranslateError(friendly); await tyTracker.fail(new Error(friendly)); toast.error(friendly, { description: msg.startsWith('TYRANO_') ? undefined : msg }); + void tryRuntimeFallback('failure', {}, 'run-failed'); } finally { setAutoTranslateBusy(false); setAutoTranslateProgress(''); @@ -2767,6 +2775,7 @@ export default function GameDetailPage() { }, duration: 10000, }); + void tryRuntimeFallback('failure', { injected: 0 }); } } finally { setAutoTranslateBusy(false); @@ -2833,6 +2842,7 @@ export default function GameDetailPage() { description: 'Niente stringhe estraibili dai file per questo motore.', action: { label: 'Apri OCR', onClick: () => router.push(`/ocr-translator?${params.toString()}`) }, }); + void tryRuntimeFallback('failure', { injected: 0 }); setAutoTranslateBusy(false); setAutoTranslateProgress(''); autoTranslateRunningRef.current = false; diff --git a/lib/i18n/locales/de.json b/lib/i18n/locales/de.json index 7ae2b094..25abdc2d 100644 --- a/lib/i18n/locales/de.json +++ b/lib/i18n/locales/de.json @@ -962,7 +962,8 @@ "unknown-process": "Spiel-Programmdatei nicht erkannt: Es gibt keinen Prozess, auf den zugegriffen werden kann." }, "runtimeFallbackReady": "Laufzeitübersetzung aktiv: Die Zeilen erscheinen während des Spielens übersetzt.", - "runtimeFallbackFailed": "Laufzeitübersetzung konnte nicht gestartet werden." + "runtimeFallbackFailed": "Laufzeitübersetzung konnte nicht gestartet werden.", + "runtimeFallbackAfterFailure": "Die dateibasierte Übersetzung ist nicht gelungen. Wenn du möchtest, starte das Spiel und versuche die Übersetzung auf dem Bildschirm." }, "danganronpaPatcher": { "errLoadDrat": "Fehler beim Laden der DRAT-Informationen:", diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index f95eb397..1b5cb602 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -962,7 +962,8 @@ "unknown-process": "Game executable not identified: no process to act on." }, "runtimeFallbackReady": "Runtime translation active: lines will appear translated as you play.", - "runtimeFallbackFailed": "Could not start runtime translation." + "runtimeFallbackFailed": "Could not start runtime translation.", + "runtimeFallbackAfterFailure": "File-based translation did not succeed. If you like, launch the game and try translating on screen." }, "danganronpaPatcher": { "errLoadDrat": "Error loading DRAT info:", diff --git a/lib/i18n/locales/es.json b/lib/i18n/locales/es.json index 1fff9d6c..5b251e4f 100644 --- a/lib/i18n/locales/es.json +++ b/lib/i18n/locales/es.json @@ -962,7 +962,8 @@ "unknown-process": "Ejecutable del juego no identificado: no hay ningún proceso sobre el que actuar." }, "runtimeFallbackReady": "Traducción en tiempo de ejecución activa: las líneas aparecerán traducidas mientras juegas.", - "runtimeFallbackFailed": "No se pudo iniciar la traducción en tiempo de ejecución." + "runtimeFallbackFailed": "No se pudo iniciar la traducción en tiempo de ejecución.", + "runtimeFallbackAfterFailure": "La traducción en los archivos no se completó. Si quieres, inicia el juego y prueba la traducción en pantalla." }, "danganronpaPatcher": { "errLoadDrat": "Error al cargar la información de DRAT:", diff --git a/lib/i18n/locales/fr.json b/lib/i18n/locales/fr.json index 61159a67..636030c3 100644 --- a/lib/i18n/locales/fr.json +++ b/lib/i18n/locales/fr.json @@ -962,7 +962,8 @@ "unknown-process": "Exécutable du jeu non identifié : aucun processus sur lequel agir." }, "runtimeFallbackReady": "Traduction à l'exécution active : les lignes apparaîtront traduites pendant que vous jouez.", - "runtimeFallbackFailed": "Impossible de démarrer la traduction à l'exécution." + "runtimeFallbackFailed": "Impossible de démarrer la traduction à l'exécution.", + "runtimeFallbackAfterFailure": "La traduction par fichiers n'a pas abouti. Si vous le souhaitez, lancez le jeu et essayez la traduction à l'écran." }, "danganronpaPatcher": { "errLoadDrat": "Erreur lors du chargement des infos DRAT :", diff --git a/lib/i18n/locales/it.json b/lib/i18n/locales/it.json index 9ab97489..78985326 100644 --- a/lib/i18n/locales/it.json +++ b/lib/i18n/locales/it.json @@ -962,7 +962,8 @@ "unknown-process": "Eseguibile del gioco non identificato: non so in quale processo agire." }, "runtimeFallbackReady": "Traduzione a runtime attiva: le righe appariranno tradotte mentre giochi.", - "runtimeFallbackFailed": "Attivazione della traduzione a runtime fallita." + "runtimeFallbackFailed": "Attivazione della traduzione a runtime fallita.", + "runtimeFallbackAfterFailure": "La traduzione sui file non è riuscita. Se vuoi, avvia il gioco e prova la traduzione a schermo." }, "danganronpaPatcher": { "errLoadDrat": "Errore caricamento info DRAT:", diff --git a/lib/i18n/locales/ja.json b/lib/i18n/locales/ja.json index e1fb6b99..1f4b878d 100644 --- a/lib/i18n/locales/ja.json +++ b/lib/i18n/locales/ja.json @@ -962,7 +962,8 @@ "unknown-process": "ゲームの実行ファイルを特定できません。操作対象のプロセスがありません。" }, "runtimeFallbackReady": "実行時翻訳が有効です。プレイ中に各行が翻訳されて表示されます。", - "runtimeFallbackFailed": "実行時翻訳を開始できませんでした。" + "runtimeFallbackFailed": "実行時翻訳を開始できませんでした。", + "runtimeFallbackAfterFailure": "ファイルによる翻訳は成功しませんでした。よければゲームを起動して、画面上の翻訳を試してください。" }, "danganronpaPatcher": { "errLoadDrat": "DRAT情報の読み込みエラー:", diff --git a/lib/i18n/locales/ko.json b/lib/i18n/locales/ko.json index 435284a2..42995a04 100644 --- a/lib/i18n/locales/ko.json +++ b/lib/i18n/locales/ko.json @@ -962,7 +962,8 @@ "unknown-process": "게임 실행 파일을 식별하지 못했습니다. 작업할 프로세스가 없습니다." }, "runtimeFallbackReady": "런타임 번역이 활성화되었습니다. 플레이 중 각 줄이 번역되어 표시됩니다.", - "runtimeFallbackFailed": "런타임 번역을 시작하지 못했습니다." + "runtimeFallbackFailed": "런타임 번역을 시작하지 못했습니다.", + "runtimeFallbackAfterFailure": "파일 기반 번역에 실패했습니다. 원하시면 게임을 실행하고 화면 번역을 사용해 보세요." }, "danganronpaPatcher": { "errLoadDrat": "DRAT 정보 로드 오류:", diff --git a/lib/i18n/locales/pl.json b/lib/i18n/locales/pl.json index 6d41c5a9..421d0636 100644 --- a/lib/i18n/locales/pl.json +++ b/lib/i18n/locales/pl.json @@ -962,7 +962,8 @@ "unknown-process": "Nie rozpoznano pliku wykonywalnego gry: brak procesu, na którym można działać." }, "runtimeFallbackReady": "Tłumaczenie w czasie działania aktywne: wiersze pojawią się przetłumaczone podczas gry.", - "runtimeFallbackFailed": "Nie udało się uruchomić tłumaczenia w czasie działania." + "runtimeFallbackFailed": "Nie udało się uruchomić tłumaczenia w czasie działania.", + "runtimeFallbackAfterFailure": "Tłumaczenie przez pliki się nie powiodło. Jeśli chcesz, uruchom grę i wypróbuj tłumaczenie na ekranie." }, "danganronpaPatcher": { "errLoadDrat": "Błąd wczytywania informacji DRAT:", diff --git a/lib/i18n/locales/pt.json b/lib/i18n/locales/pt.json index 2665eb7e..0d7fd25e 100644 --- a/lib/i18n/locales/pt.json +++ b/lib/i18n/locales/pt.json @@ -962,7 +962,8 @@ "unknown-process": "Executável do jogo não identificado: não há processo sobre o qual agir." }, "runtimeFallbackReady": "Tradução em tempo de execução ativa: as linhas aparecerão traduzidas enquanto joga.", - "runtimeFallbackFailed": "Não foi possível iniciar a tradução em tempo de execução." + "runtimeFallbackFailed": "Não foi possível iniciar a tradução em tempo de execução.", + "runtimeFallbackAfterFailure": "A tradução pelos ficheiros não foi bem-sucedida. Se quiser, inicie o jogo e experimente a tradução no ecrã." }, "danganronpaPatcher": { "errLoadDrat": "Erro ao carregar informações do DRAT:", diff --git a/lib/i18n/locales/ru.json b/lib/i18n/locales/ru.json index 3a7931fd..8b25283a 100644 --- a/lib/i18n/locales/ru.json +++ b/lib/i18n/locales/ru.json @@ -962,7 +962,8 @@ "unknown-process": "Исполняемый файл игры не определён: нет процесса, с которым можно работать." }, "runtimeFallbackReady": "Перевод во время игры включён: строки будут появляться переведёнными по ходу игры.", - "runtimeFallbackFailed": "Не удалось запустить перевод во время игры." + "runtimeFallbackFailed": "Не удалось запустить перевод во время игры.", + "runtimeFallbackAfterFailure": "Перевод через файлы не удался. При желании запустите игру и попробуйте перевод на экране." }, "danganronpaPatcher": { "errLoadDrat": "Ошибка загрузки информации DRAT:", diff --git a/lib/i18n/locales/zh.json b/lib/i18n/locales/zh.json index cc53a8fc..413d2668 100644 --- a/lib/i18n/locales/zh.json +++ b/lib/i18n/locales/zh.json @@ -962,7 +962,8 @@ "unknown-process": "未能识别游戏可执行文件:没有可操作的进程。" }, "runtimeFallbackReady": "运行时翻译已启用:游玩过程中各行文本将显示为译文。", - "runtimeFallbackFailed": "无法启动运行时翻译。" + "runtimeFallbackFailed": "无法启动运行时翻译。", + "runtimeFallbackAfterFailure": "通过文件的翻译未能成功。如果需要,可以启动游戏并尝试屏幕翻译。" }, "danganronpaPatcher": { "errLoadDrat": "加载 DRAT 信息时出错:", diff --git a/lib/translation/runtime-fallback.ts b/lib/translation/runtime-fallback.ts index 8757243d..03df3920 100644 --- a/lib/translation/runtime-fallback.ts +++ b/lib/translation/runtime-fallback.ts @@ -86,15 +86,38 @@ export function planRuntimeFallback(ctx: RuntimeContext): RuntimePlan { : { action: 'await-launch', processName: ctx.processName }; } -/** Chiave i18n del messaggio da mostrare per un piano. */ -export function runtimePlanMessageKey(plan: RuntimePlan): string { +/** + * Perché la strada statica non ha inciso. Cambia cosa possiamo onestamente + * promettere, non cosa facciamo. + */ +export type FallbackCause = + /** Il motore non espone testo nei file: la strada statica non c'è, punto. */ + | 'engine-unsupported' + /** Questa run è fallita — magari per configurazione, magari no. */ + | 'run-failed'; + +/** + * Chiave i18n del messaggio da mostrare per un piano. + * + * A gioco chiuso il messaggio dipende dalla causa: «questo gioco non si lascia + * tradurre nei file» è vero quando il motore non espone testo, ed è una bugia + * quando è appena caduto Ollama. In quel caso si offre il runtime come + * alternativa, senza dichiarare impossibile una strada che potrebbe funzionare + * benissimo alla prossima prova. + */ +export function runtimePlanMessageKey( + plan: RuntimePlan, + cause: FallbackCause = 'engine-unsupported', +): string { switch (plan.action) { case 'none': return ''; case 'inject': return 'gameDetail.runtimeFallbackInjecting'; case 'await-launch': - return 'gameDetail.runtimeFallbackAwaitLaunch'; + return cause === 'engine-unsupported' + ? 'gameDetail.runtimeFallbackAwaitLaunch' + : 'gameDetail.runtimeFallbackAfterFailure'; case 'unavailable': return `gameDetail.runtimeFallbackBlocked.${plan.blocker}`; } @@ -117,6 +140,8 @@ export interface RunReport { staticOutcome: PatchOutcome; /** Piano scelto dopo la strada statica. */ plan: RuntimePlan; + /** Perché la strada statica non ha inciso. */ + cause: FallbackCause; /** Chiave i18n del passo successivo suggerito. */ nextStepKey: string; } @@ -128,12 +153,15 @@ export function buildRunReport(args: { stringsInjected?: number | null; stringsTotal?: number | null; plan: RuntimePlan; + cause?: FallbackCause; }): RunReport { const attempted: AttemptedPath[] = ['static']; if (args.plan.action === 'inject') { attempted.push('runtime'); } + const cause = args.cause ?? 'engine-unsupported'; + return { gameTitle: args.gameTitle, engine: args.engine ?? null, @@ -142,7 +170,8 @@ export function buildRunReport(args: { stringsTotal: args.stringsTotal ?? null, staticOutcome: args.staticOutcome, plan: args.plan, - nextStepKey: runtimePlanMessageKey(args.plan), + cause, + nextStepKey: runtimePlanMessageKey(args.plan, cause), }; }