# Локализация и моды — проект Цель: переводить и модифицировать игры прямо в библиотеке, нативно и удобно. Переводы из самих игр импортируются при конвертации, дальше живут только в нашем формате. ## Почему это нужно решить сейчас Исходники игры удаляются после конвертации. Всё, что не вытащено в момент конвертации (готовые переводы из `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//`: * блок `translate ` сопоставляем с нашей репликой по комментарию `# game/file.rpy:строка` (точно и просто), запасной вариант — по ID Ren'Py; * `translate 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 не спрашивал про локальную сеть без причины; * вкладка «Имена и меню» собирает не только имена, пункты меню и `show text "…"`, но и строки `_("…")` из скриптов и постоянный текст экранов (`text`, `textbutton`, `label`): у них нет идентификатора реплики, поэтому они переводятся по тексту оригинала, как в Ren'Py. Список строится из уже конвертированной версии, повторный импорт игры не нужен; * декоративные теги 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`. Сообщение `Model output was truncated or filtered` приходит от local-translator, когда LM Studio возвращает `finish_reason=length` (исчерпан лимит ответа) либо `content_filter`. По одному этому тексту отличить причины нельзя: для диагностики нужны метрики вызова переводчика. Пачка ограничивается отдельно по числу строк и по символам; лимит выходных токенов задаётся в конфигурации провайдера local-translator. Повтор после такой ошибки использует вдвое меньшую пачку (минимум одну строку). Успешный повтор убирает устаревшее сообщение ошибки модели. Сбой получения строк или сохранения в библиотеку показывается отдельно с этапом и HTTP-кодом, если сервер не вернул текст ошибки. Готовые результаты задания и ключи уже сохранённых строк остаются в контрольной точке: возобновление не отправляет готовое задание заново и не перезаписывает эти строки. Ограничения: перевод идёт, пока открыта страница редактора; контекст (кто говорит, соседние реплики) модели пока не передаётся, только текст строки; Safari может запрещать https-странице обращаться к локальному http. Уже сконвертированные версии остаются без ID и без переводов, их нужно переимпортировать. Пока в библиотеке только тестовые данные, отдельную миграцию не делаем. ## Этапы 1. **Фундамент (сейчас, до наполнения библиотеки):** ID узлов и строк в конвертере, импорт `tl/` всех языков, локали в манифесте, выбор языка и запасные языки в плеере, `_()`/`!t`/`_preferences.language`. Миграция уже сконвертированных версий: ID детерминированно выводятся из текущего IR. 2. **Перевод в библиотеке:** главная версия, черновик и публикация, права, редактор перевода, прогресс, экспорт и импорт PO/CSV. 3. **Удобство:** контекст сцены, «проиграть отсюда», глоссарий, рецензирование, шрифты и ассеты локалей, машинный предперевод. 4. **Моды:** редактор скриптов, замена ассетов, слияние и перенос модов.