# BuildSwitcher

## Обзор класса

`BuildSwitcher` меняет активную сборку: содержимое `.minecraft` уезжает в архив своей сборки,
папка чистится, на её место разворачивается архив выбранной. Он же докатывает пак сезонной
сборки поверх уже разложенного содержимого.

Порядок шагов подчинён одному правилу: пока новый архив не записан целиком и не переименован на
место, из `.minecraft` не удаляется ничего. Отметка о начатом переключении пишется в
`builds/index.json` до первого разрушающего действия, поэтому обрыв питания или принудительное
завершение процесса всегда обнаружим на следующем запуске.

Сами файловые операции выполняет [BuildArchiveWorker](BuildArchiveWorker.md) в отдельном потоке;
`BuildSwitcher` — это порядок шагов, учёт состояния и восстановление после сбоя.

## Место в проекте и зависимости

Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Класс создаёт и целиком
владеет своим `QThread` и объектом [BuildArchiveWorker](BuildArchiveWorker.md).

Пути к папкам сборок и файлу состояния даёт [launcherpaths.h](launcherpaths.md)
(`buildStorageDir()`, `buildDir()`).

Требования сборки: `Qt6::Core` (`QThread`, `QJsonObject`) и `Qt6::CorePrivate` — опосредованно,
через исполнителя.

## Иерархия и роль

Наследует `QObject`: мета-объектная система, три сигнала и владение по родителю. Объявлен
виртуальный деструктор — класс обязан корректно остановить свой поток.

## Публичные методы

#### explicit BuildSwitcher(QObject \*parent = nullptr)

Создаёт переключатель, рабочий поток и исполнителя, переносит второй в первый и запускает поток.
Конструктор помечен `explicit`.

#### bool isRunning() const

Идёт ли сейчас переключение или раскатка пака.

#### QString stage() const

Текущий этап словами — заголовок панели прогресса.

#### QString status() const

Строка состояния: что обрабатывается сейчас.

#### double fraction() const

Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен.

#### static QStringList sharedTopLevel()

Общие для всех сборок каталоги верхнего уровня. Они не архивируются и не чистятся: принадлежат
лаунчеру, а не какой-либо сборке. Список передаётся исполнителю как `excludeTop` при упаковке и
как `keepTop` при очистке.

#### void switchTo(int fromBuildId, const QString &fromName, int toBuildId, const QString &toName, const QString &gameDir)

Переключает активную сборку. Проходит четыре этапа: упаковка текущего содержимого во временный
файл, фиксация архива переименованием на место, очистка папки игры и распаковка архива целевой
сборки.

Имена сборок нужны для текста в панели прогресса, идентификаторы — для путей к архивам.

#### void applyPack(int buildId, const QString &buildName, const QString &packZipPath, const QStringList &removeRelative, const QJsonObject &note, const QString &gameDir)

Докатывает пак сезонной сборки поверх содержимого `.minecraft`. Вызывается только для уже активной
сборки: пак ложится на то, что сейчас разложено, а не внутрь чужого архива.

Параметр `removeRelative` — файлы, ушедшие из сборки в новой ревизии; они удаляются перед
распаковкой. Всего остального операция не касается, поэтому миры и скриншоты игрока переживают
обновление.

Параметр `note` — непрозрачные данные вызывающей стороны. Они переживают перезапуск вместе с
отметкой о незавершённой операции и возвращаются через `lastPackNote()`, когда раскатка доиграна.
Сам переключатель в них не заглядывает.

#### QStringList lastPackEntries() const

Что принёс последний успешно раскатанный пак — относительными путями. Список нужен следующему
обновлению, чтобы вычислить, какие файлы из сборки ушли.

#### QJsonObject lastPackNote() const

Данные, с которыми пришёл последний успешно раскатанный пак, — те самые, что передавались в
`applyPack()`.

#### void cancel()

Отменяет текущую операцию. Отмена доходит до исполнителя через признак, проверяемый между
файлами.

Отменять имеет смысл только на этапе упаковки: после очистки `.minecraft` отступать некуда, и
операцию нужно довести до конца. Именно поэтому панель прогресса смены сборки объявлена
неотменяемой.

#### bool forgetBuild(int buildId)

Сборку удалили — убирает её архив и запись о нём. Возвращает `false`, если папку архива не удалось
удалить целиком.

#### bool hasArchive(int buildId) const

Есть ли у сборки сохранённый архив. Используется в тексте предупреждения об удалении: вместе со
сборкой пропадут её моды, конфиги и миры.

#### QString interruptedSwitchWarning() const

Незавершённое переключение с прошлого запуска. Возвращает пустую строку, если всё в порядке, иначе
— готовый текст для пользователя.

Проверяется при старте лаунчера: отметка в `builds/index.json` пишется до первого разрушающего
действия, поэтому прерванная операция обнаруживается всегда.

#### void resumeInterrupted(const QString &gameDir)

Доигрывает прерванное переключение: очистку и распаковку целевой сборки. Архив исходной сборки к
этому моменту уже записан — правило порядка шагов это гарантирует.

## Сигналы

#### progressChanged()

Изменились числа прогресса. Обработчик перечитывает `stage()`, `status()` и `fraction()`.

#### finished(int toBuildId)

Переключение или раскатка завершены успешно; в параметре — идентификатор сборки, ставшей активной.

Обработчик снимает признак переключения, обновляет активную сборку и, после раскатки пака,
забирает `lastPackEntries()` и `lastPackNote()`, чтобы записать их в описание сезонной сборки.

#### failed(int toBuildId, const QString &message, bool gameDirIntact)

Операция не удалась. Третий параметр — ключевой: он говорит, цела ли папка игры. Неудача на этапе
упаковки оставляет `.minecraft` нетронутой, неудача после очистки — нет, и сообщение пользователю
должно различать эти случаи.

## Владение и время жизни

Класс наследует `QObject` и принимает `parent` — родитель его и удалит.

Рабочий поток и исполнитель создаются в конструкторе и принадлежат переключателю. Исполнитель
родителя не имеет — объект, живущий в другом потоке, не может принадлежать объекту из потока GUI;
его удаление привязано к завершению потока. Деструктор обязан остановить поток и дождаться его
завершения, иначе рабочая операция переживёт своего владельца.

Временный архив на диске переживает аварийное завершение процесса; накопившиеся временные файлы
подчищаются при следующем запуске.

## Потокобезопасность

Сам переключатель живёт в потоке GUI: все его публичные методы вызываются оттуда. Тяжёлая работа
вынесена в отдельный поток к [BuildArchiveWorker](BuildArchiveWorker.md), общение с которым идёт
исключительно через очередь сигналов — прямых вызовов слотов исполнителя нет.

Единственный межпотоковый примитив — атомарный признак отмены внутри исполнителя.

## Взаимодействие с другими классами

`LauncherBackend` вызывает `switchTo()` при смене активной сборки, `applyPack()` — при установке
или обновлении сезонной сборки, `forgetBuild()` — при удалении сборки. Сигналы переключателя он
переправляет в свойства, которые читает QML: панель прогресса главного окна показывает `stage()`,
`status()` и `fraction()`, а признак переключения блокирует список сборок и кнопки в
[BuildsDialog](../qml/BuildsDialog.md).

При старте бэкенд спрашивает `interruptedSwitchWarning()` и, если операция была прервана,
предлагает доиграть её через `resumeInterrupted()`.

Архив сезонной сборки к моменту вызова `applyPack()` уже скачан
[SeasonalPackDownloader](SeasonalPackDownloader.md).

## Внешнее взаимодействие

**Файловая система.** Класс читает и пишет `builds/index.json` — отметку о состоянии переключения,
— управляет папками архивов сборок и подчищает временные файлы. Все операции с содержимым самой
папки игры делегированы исполнителю в рабочем потоке.

Сети и дочерних процессов у класса нет.

## Пример использования

```cpp
auto *switcher = new BuildSwitcher(this);

connect(switcher, &BuildSwitcher::progressChanged, this, &Backend::switchChanged);
connect(switcher, &BuildSwitcher::finished, this, &Backend::onSwitchFinished);
connect(switcher, &BuildSwitcher::failed, this,
        [this](int, const QString &message, bool gameDirIntact) {
            emit launchError(gameDirIntact
                             ? message
                             : tr("%1. Папка игры осталась незавершённой.").arg(message));
        });

// при старте лаунчера
const QString warning = switcher->interruptedSwitchWarning();
if (!warning.isEmpty()) {
    showStatus(warning);
    switcher->resumeInterrupted(gameDir);
}
```

---

При создании этого документа использовался ИИ.
