Files
pgames-manager/docs/LOCALIZATION.md
T
mrleo1nidandClaude Sonnet 5.5 262db2687e
CI / frontend (push) Failing after 24s
CI / backend (push) Failing after 45s
feat: lines with style tags inside words are translated without them and marked fuzzy
Decorative Ren'Py tags cannot survive translation mid-word; retry without them when the translator rejects the answer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 20:57:46 +03:00

283 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Локализация и моды — проект
Цель: переводить и модифицировать игры прямо в библиотеке, нативно и удобно.
Переводы из самих игр импортируются при конвертации, дальше живут только в
нашем формате.
## Почему это нужно решить сейчас
Исходники игры удаляются после конвертации. Всё, что не вытащено в момент
конвертации (готовые переводы из `tl/`, привязка реплик к их месту в
исходнике), потом взять будет неоткуда. Поэтому стабильные идентификаторы
и импорт переводов должны появиться в конвертере до того, как библиотека
наполнится играми.
## Что есть в играх (по примерам)
| Игра | Языки в `tl/` | Переведённых реплик |
|---|---|---|
| That New Teacher | 19 (в т.ч. russian) | ~350 000 блоков |
| Twisted Thoughts | 9; исходник на французском, english — перевод | ~117 000 |
| остальные 4 | только исходный | — |
Формат Ren'Py:
```
# game/Days/dayEight.rpy:248
translate russian D8_Clarissa_c7855708:
# "You lie on your side, facing away..."
"Ты лежишь на боку, отвернувшись..."
translate russian strings:
old "Iris"
new "Айрис"
```
Реплики переводятся блоками с идентификатором (`метка_хэш`) и ссылкой на
строку оригинала; имена, пункты меню и строки интерфейса — таблицей old/new.
Бывает и игровая логика, зависящая от языка (`_preferences.language`).
## Главная идея: текст отдельно от скрипта, у всего стабильные ID
1. **ID узлов.** Каждый оператор скрипта получает постоянный `id`. Он
присваивается один раз при конвертации и дальше не меняется при правках.
На этих ID держатся переводы, сохранения и в будущем слияние модов.
2. **ID строк.** Реплики получают `tid` в стиле Ren'Py (`метка_хэш`) и
хранят его в скрипте, так что правки текста ID не меняют. Пункты меню,
имена, `_()` и `show text` переводятся таблицей строк по исходному тексту,
как в Ren'Py.
3. **Исходный текст остаётся в скрипте.** Скрипт читается как есть, язык
оригинала — это просто «исходная локаль» (у Twisted Thoughts — французский).
4. **Переводы — отдельные таблицы** `locales/<язык>/<модуль>.json`:
```json
{ "start_577f33b3": { "t": "Ммм...", "src": "a1b2c3", "state": "translated" } }
```
`src` — хэш исходного текста на момент перевода. Если в моде или новой
версии оригинал реплики изменился, перевод помечается «устарел», а не
теряется молча. Состояния: `translated`, `fuzzy`/устарел, `machine`,
`approved`.
5. **Файлы локали режутся по модулям скрипта.** Правка одной реплики создаёт
один новый маленький блоб — остальное общее (наш content-addressed store).
`locales/<язык>/meta.json`: код языка (BCP-47: `ru`), название, авторы,
прогресс, шрифт-замена (у многих игр в шрифтах нет кириллицы или CJK),
переопределения ассетов для языка: картинки с надписями, озвучка.
## Импорт переводов из игры
При конвертации разбираем `tl/<lang>/`:
* блок `translate <lang> <id>` сопоставляем с нашей репликой по комментарию
`# game/file.rpy:строка` (точно и просто), запасной вариант — по ID Ren'Py;
* `translate <lang> strings` превращаем в таблицу old→new для имён, меню и `_()`;
* языки Ren'Py (`russian`, `spanish_latam`, `None`) переводим в коды `ru`,
`es-419` и исходную локаль;
* импортируются все языки игры — это только текст, места почти не занимает.
## Плеер
* Выбор языка в главном меню и настройках, запоминается для каждого
пользователя.
* Цепочка запасных языков: `ru → en → исходный`. Непереведённая строка
показывается на запасном языке и не ломает игру.
* `_()`, флаг `!t` в `[переменных]`, имена персонажей переводятся из таблиц;
`_preferences.language` отдаёт выбранный язык, поэтому языковая логика
игры (как в Twisted Thoughts) работает.
* Шрифт и ассеты подменяются из `meta.json` локали.
## Версии и публикация
Сценарий:
1. Модератор делает форк главной версии: «Перевод RU (черновик)».
2. Переводит в редакторе. Форк виден только ему и тем, у кого есть права
на рецензию.
3. Нажимает «Опубликовать перевод».
**Публикация переносит локаль, а не подменяет версию.** Если два
переводчика параллельно делают RU и DE в двух форках, «сделать форк главным»
потеряет работу одного из них. Поэтому публикация создаёт новую главную
версию: главная + локаль из форка. Переводы склеиваются по `tid` построчно,
это тривиально. Перенос перевода в любую совместимую версию (например, в мод)
работает так же — одной кнопкой, по тем же ID.
Модели:
* `games.main_version_id` — главная версия (её открывает кнопка «Играть»);
* у версии `visibility`: `draft` / `published`, а также `kind`: оригинал,
перевод, мод;
* новые права: `translations.edit` — переводить в своих форках,
`versions.publish` — публиковать, назначать главную.
## Редактор перевода
* Таблица строк по сценам и меткам: оригинал | перевод | статус, фильтры
«не переведено / устарело / машинное», поиск, прогресс в процентах.
* Контекст строки: кто говорит, соседние реплики, фон и спрайты сцены на этом
месте, кнопка «проиграть с этой строки» в плеере.
* Глоссарий: имена и термины подставляются и проверяются на единообразие.
* Экспорт и импорт PO/XLIFF/CSV для внешних инструментов, экспорт обратно
в формат Ren'Py `tl/`.
* Позже: режим «править прямо в игре» — клик по реплике в плеере;
машинный предперевод с пометкой `machine`.
## Моды
Те же основы делают моды нативными:
* скрипт редактируется на уровне меток и операторов с постоянными ID;
* мод — форк с изменёнными модулями скрипта и ассетами: в хранилище только
отличия;
* переводы оригинала автоматически применяются к неизменённым репликам мода,
новые реплики мода видны как непереведённые;
* так как у всего есть ID и родитель, можно делать трёхстороннее слияние:
мод A + мод B, или перенос мода на новую версию оригинала.
## Принятые решения
* Публикация перевода — **слияние локали** в новую главную версию, а не
подмена главной версии форком.
* Из игр импортируются **все языки** `tl/`.
* Машинный предперевод — **на стороне клиента**: переводит программа
[local-translator](https://gitea.hsrv.site/mrleo1nid/local-translator) на
компьютере переводчика, сервер библиотеки о ней не знает (см. ниже).
* Начинаем с этапа 1.
## Состояние
Этап 1 сделан:
* у всех узлов скрипта есть `id`, у реплик — `tid`;
* при конвертации импортируются все языки из `tl/`. Блок сопоставляется
с репликой по комментарию `# game/file.rpy:N`, а если он устарел — по
метке и исходному тексту. Отдельные таблицы `strings` тоже импортируются;
* исходный язык берётся из `config.language` или определяется по тексту
(у Twisted Thoughts — французский);
* в манифесте лежат `locales/<код>/<модуль>.json` и `locales/<код>/strings.json`,
в `game.json` — `sourceLang` и `locales` с прогрессом;
* в плеере есть выбор языка в главном меню и настройках, запасной язык —
исходный, работают `__()`, `!t`, `_preferences.language`.
Проверено на примерах: Twisted Thoughts — 9 языков, по 12 949 реплик,
100% сопоставлено. That New Teacher — 18 языков, около 75% реплик: остальное
авторы ещё не перевели, а ~750 блоков на язык относятся к уже изменённым
английским репликам. Ren'Py их тоже не показывает.
Этапы 2 и 3 сделаны:
* у игры есть главная версия (`games.main_version_id`), у версий —
`visibility` (`draft`/`published`) и `kind` (`original`/`translation`/`mod`);
миграция `0002` проставила главную версию существующим играм;
* права `translations.edit` и `versions.publish`, у системной роли
moderator они включены;
* кнопка «Перевести» создаёт черновик-форк вида «перевод», «Опубликовать»
вливает изменённые языки **в саму главную версию**: её ID не меняется,
сохранения игроков остаются. Прежний манифест пишется в `version_history`
(`GET /api/versions/{id}/history`, откат —
`POST …/history/{hid}/restore`);
* редактор перевода `/versions/{id}/translate`:
* языки с прогрессом по состояниям, реплики и строки (имена, меню, текст
на экране);
* фильтры «к работе / не переведено / устарело / неточно / машинный /
переведено / одобрено», поиск, фильтр по файлу скрипта;
* автосохранение, Ctrl+Enter — к следующей строке;
* кнопки «одобрить» (только у публикующих), «актуально» для устаревших,
«неточно», «скопировать оригинал»;
* контекст строки: фон сцены, соседние реплики, «играть отсюда»
(`/play/{id}?at=<узел>&lang=<язык>`, картинка сцены восстанавливается);
* глоссарий с подсветкой терминов в оригинале;
* экспорт и импорт PO/CSV, шрифт для языка;
* API: `GET/PUT /api/versions/{id}/i18n[/{lang}]`, `…/entries`, `…/meta`,
`…/font`, `…/export`, `…/import`, `DELETE …/{lang}`.
Пока не сделано: UI истории версии (сейчас есть только API), картинки и
озвучка для отдельного языка, правка реплик прямо в плеере.
### Машинный перевод через локальный переводчик
Страница `/translator` («Локальный переводчик» в меню профиля) объясняет, как
поставить LM Studio и local-translator, какую модель взять по объёму
видеопамяти, и проверяет связь прямо из браузера. В редакторе перевода кнопка
«Машинный перевод» открывает панель: источник и модель, строк за запрос,
пожелания к стилю (запоминаются для игры), «имена и интерфейс», «обновить
устаревшие».
Как это работает:
* браузер сам ходит на `http://127.0.0.1:8787` (адрес можно сменить на странице
настройки); библиотека получает только обычные `PUT …/i18n/{lang}` с
состоянием `machine`, ни адреса, ни моделей она не видит;
* для каждой игры и пары языков в переводчике создаётся проект
`PGames: <игра> (<исходный> → <язык>)`: контекст из описания игры, глоссарий
из вкладки «Глоссарий», пожелания пользователя. Проект находится по имени и
обновляется перед каждым запуском;
* запуск живёт в провайдере выше страниц (`translator/runner.tsx`): перевод идёт,
пока вкладка открыта, даже если уйти из редактора; его карточка с прогрессом и
кнопкой «Остановить» стоит в том же плавающем окне, что и загрузки. Одновременно
работает только один перевод (на все игры и языки; между вкладками это
держит Web Lock), пока он идёт, кнопки
отдельных строк недоступны; закрытие вкладки браузер подтверждает. На каждой странице запуск
записывается в `localStorage` (`pg.translation.run`: что переводится, где
остановились, какое задание отправлено в переводчик). После обновления страницы
сайт сам продолжает: забирает готовое задание из переводчика, сохраняет его и идёт
дальше, а карточка и бейджи снова обновляются. Запись старше 30 минут не
подхватывается;
* автоперевод идёт по списку так, как его видит пользователь: текущая
вкладка («Реплики» или «Имена и меню»), фильтр по файлу и поиск, сверху вниз.
Берутся строки «не переведено» (и «устарело» по желанию) страницами по 40;
страница уходит одним заданием, готовое сразу сохраняется, появляется в
открытых строках и обновляет прогресс в шапке;
* список разбит на страницы (20/40/100/200 строк, номера страниц, переход по
номеру); сервер в `GET …/entries` по параметру `locate=<ключ>` возвращает
`located`, позицию строки в том же отфильтрованном списке. По нему режим
«Следить за переводом» (включён по умолчанию, выключается кнопкой над списком,
флажком в панели или ручным листанием) открывает страницу, на которой модель
работает сейчас, и подсвечивает строки текущей пачки;
* у каждой строки есть кнопка «Перевести» (пока переводчик подключён): она
переводит одну строку с тем же контекстом и глоссарием и помечает её
«Машинный». Редактор ищет переводчик сам, только если он уже работал в этом
браузере, чтобы Chrome не спрашивал про локальную сеть без причины;
* декоративные теги Ren'Py (`{size=…}`, `{i}`, `{b}`, `{color=…}` и т. п.) внутри
слов (`d{size=38}e{/size}ep`) после перевода на место не вернуть, поэтому такие
строки уходят модели без этих тегов, а результат помечается «Неточно». Строки с
тегами в других местах сначала переводятся как есть, а если переводчик отверг
ответ (`Protected tokens were changed…`), переводятся ещё раз без декоративных
тегов и тоже получают «Неточно». Служебные теги (`{w}`, `{nw}`, `{p}`), `[переменные]`
и прочее остаются под защитой, такая строка при сбое просто не переводится;
* флажок «Передавать описание и жанры игры как контекст» (по умолчанию включён)
добавляет описание и теги игры в контекст проекта; название игры уходит всегда; Остановка
отменяет задание в переводчике; повторный запуск продолжает с оставшегося.
Непереведённые из-за ошибки строки (например, модель повредила плейсхолдер)
пропускаются и видны в счётчике ошибок;
* переводчик по умолчанию закрыт для браузера. Адрес сайта вписывается в
`allowed_origins` его `config.toml` (точный адрес, без `*`); для Chrome сервер
отвечает на заголовок Private Network Access, а сам браузер один раз спросит
разрешение на доступ к локальной сети. Страница настройки отличает «не запущен»
от «запущен, но сайт не разрешён»;
* код: `frontend/src/translator/` (`client.ts` — API переводчика, `run.ts` —
цикл перевода, `MachineTranslate.tsx` — панель, `TranslatorSetup.tsx` —
страница настройки); проверки в `frontend/tests/translator-run.test.mjs`.
Ограничения: перевод идёт, пока открыта страница редактора; контекст
(кто говорит, соседние реплики) модели пока не передаётся, только текст строки;
Safari может запрещать https-странице обращаться к локальному http.
Уже сконвертированные версии остаются без ID и без переводов, их нужно
переимпортировать. Пока в библиотеке только тестовые данные, отдельную
миграцию не делаем.
## Этапы
1. **Фундамент (сейчас, до наполнения библиотеки):** ID узлов и строк в
конвертере, импорт `tl/` всех языков, локали в манифесте, выбор языка и
запасные языки в плеере, `_()`/`!t`/`_preferences.language`.
Миграция уже сконвертированных версий: ID детерминированно выводятся
из текущего IR.
2. **Перевод в библиотеке:** главная версия, черновик и публикация, права,
редактор перевода, прогресс, экспорт и импорт PO/CSV.
3. **Удобство:** контекст сцены, «проиграть отсюда», глоссарий,
рецензирование, шрифты и ассеты локалей, машинный предперевод.
4. **Моды:** редактор скриптов, замена ассетов, слияние и перенос модов.