Files
evening_detective_server/docs/GAME_PACKAGE.md
T
2026-10-10 18:32:41 +07:00

13 KiB

Пакет игры, версия 1 — ED-029, этап 4

Пакет — самостоятельный ZIP для одной игры до старта. Сборка получает готовый снимок данных и файлы; чтение не обращается к сети, БД или хранилищу приложения. Реализация: internal/services/game_package_service.

Контракт

Запись Содержание
manifest.json version=1, случайный package_id (32 hex-символа), created_at в UTC, source_server, исходный game_id, массив files
game.json Объект Data: game, scenario, teams, tickets, playlists, необязательный screen_plan, map
files/<sha256>.<extension> Медиа с именем по SHA-256 содержимого; одинаковые байты хранятся один раз

Каждая запись files манифеста описывает путь, размер распакованных байтов, MIME и SHA-256 в нижнем регистре. В перечень входит game.json, сам манифест не входит. Все остальные ZIP-записи должны быть заявлены и использованы ссылками из данных игры. Вложенные архивы и каталоги не используются.

Полный перечень JSON-полей задан типами в model.go; неизвестные поля и неподдерживаемые версии при чтении отклоняются. Время записывается как RFC3339, token_hash — как стандартный Base64 от исходных 32 байтов SHA-256. Структура истории используется существующая — storytelling.Story; поля шагов экранов совпадают с game_screen_plans_repo.Step.

Переносимые данные

  • Игра: исходные ID, название, описание, место, запланированное время, статус, формат online/offline, ссылки на сценарий и плейлист. Допускается только draft, без started_at, ended_at и игрового прогресса.
  • Сценарий: описание, обложка, полная история с правильными ответами, статус, даты, исходный ID автора и назначенный плейлист.
  • Все команды игры, включая неподтверждённые и отменённые: ID, неизменённый пароль QR, название, статус, email капитана, число участников, данные подтверждения участия, исходный ID создателя, ID доставки регистрации и дата создания. Эти данные нужны для сохранения связей и защиты от повторной регистрации.
  • Активные и использованные билеты: ID, исходный хеш токена, статус, даты, исходный ID пользователя, использовавшего билет. used_by может отсутствовать, например после удаления пользователя; статус и used_at сохраняются.
  • Оба назначенных плейлиста — игры и сценария, если они различаются; треки с исходными ID, именами и позициями. План экранов с выбранным шагом; карта команд и единственный стол организатора organizer_desk/1.

game.playlist_id — выбранный для переноса плейлист игры. При подготовке снимка экспортёр должен учесть назначение в game_music_states.playlist_id: существующий интерфейс выбора музыки сохраняет его именно там. При отсутствии такого назначения используется games.playlist_id, затем default сценария. Текущая позиция, состояние проигрывания и сессии экранов в пакет не включаются.

ID пользователей — ссылки на исходный сервер, а не существующих пользователей площадки. Импортёр обязан обработать их отдельно; кодек не создаёт аккаунты. ID игры, команд, сценария, билетов, плейлистов и треков также относятся к исходному серверу. Кодек их не перенумеровывает. Импорт должен сохранить значения ID команд и пароль, используемые в уже напечатанных ссылках /team-story/<id>?password=..., а также хеши токенов билетов. Конфликты с локальными ID решаются на этапе 6.

Почтовые задания, токены подтверждения участия, аккаунты, пароли пользователей, JWT/refresh-токены, действия, полученные улики, ответы команд и результаты не входят в формат. Подключённые экраны и их позиции на карте при сборке исключаются; на площадке экраны подключаются заново. Состояние проигрывания музыки не переносится.

Медиа

Собираются все файловые ссылки: обложка, фон, аудио вступления, видео заключения, изображения мест, файлы улик и треки плейлистов. Данные проверяются по сигнатуре содержимого; поддерживаются PDF, JPEG, PNG, GIF, WebP, MP3, Ogg, WAV, MP4 и WebM. Ссылки внутри копии Data заменяются на пути files/...; входные данные не меняются.

Относительное имя и полный URL с настроенным префиксом хранилища открываются через переданный источник локальных файлов. Остальные HTTP/HTTPS-ссылки скачиваются при сборке. Один URL скачивается один раз; одинаковое содержимое разных URL хранится один раз. Файл по недоступной ссылке, пустой файл или ответ без поддерживаемой сигнатуры завершают сборку ошибкой.

Внешний клиент не использует системный proxy. Адреса проверяются перед соединением и после перенаправлений; соединение идёт к проверенному IP без повторного DNS-разрешения. Частные, локальные и служебные сети запрещены. HTTPS использует штатную проверку сертификатов. До пяти перенаправлений, до 120 секунд на файл; отмена контекста прерывает операцию.

Использование и временные файлы

service := game_package_service.NewGamePackageService(fileDomain)

openLocal := func(ctx context.Context, name string) (io.ReadCloser, error) {
    file, err := fileStorage.Get(ctx, name)
    if err != nil {
        return nil, err
    }
    return io.NopCloser(bytes.NewReader(file.Data)), nil
}

err := service.Pack(ctx, snapshot, openLocal, destination)
// На ошибке destination нужно отбросить, включая ошибку записи результата.

bundle, err := service.Unpack(ctx, sourceReaderAt, archiveSize, tempParent)
if err != nil {
    return err
}
defer os.RemoveAll(bundle.Dir)
// bundle.Files: путь внутри ZIP → проверенный временный файл с расширением.

Snapshot.SourceServer содержит исходный адрес онлайн-сервера. Snapshot.HasProgress заполняется экспортёром по наличию действий, полученных улик и ответов. Кодек не читает БД, поэтому достоверность снимка и этого признака обеспечивает этап 5. Data не имеет полей для игрового прогресса.

Pack сначала собирает ZIP в приватном временном каталоге, затем копирует его в destination. Ошибка получения файлов не выдаёт частичный пакет. Ошибка записи или отмена во время выдачи может оставить часть ZIP в destination; вызывающий код обязан её удалить. Все внутренние файлы Pack удаляются при завершении операции. Адаптер существующего IFileStorage.Get держит в памяти один объект; потоковый источник может возвращать reader без этого буфера.

Unpack создаёт отдельный каталог внутри tempParent (или системного временного каталога при пустой строке), с правами 0700, файлы — 0600. Файлы создаются со случайными именами, независимо от путей ZIP. При любой ошибке собственный каталог удаляется. После успеха очистка bundle.Dir обязательна для вызывающего кода. Временные файлы и пакеты нельзя хранить в Git: они содержат ответы сценария, пароли QR и email капитанов.

Лимиты и проверки

Ограничение Предел
Размер ZIP 1 ГиБ
Общий распакованный размер, включая JSON 1 ГиБ
Один медиафайл 64 МиБ
Каждый JSON и каталог ZIP 8 МиБ
Количество ZIP-записей, включая JSON 4096

Обработка медиа использует ограниченные буферы и временные файлы, не загружает весь пакет в память. Перед zip.NewReader проверяется каталог, чтобы количество записей нельзя было скрыть ложным заголовком. ZIP64 и многотомные ZIP не поддерживаются. При чтении отклоняются опасные пути, симлинки, каталоги, дубли ZIP/манифеста, незаявленные и неиспользуемые файлы, отсутствие файлов, несовпадение размеров, MIME и SHA-256, некорректные связи данных и состояние начатой игры.

Ошибки проверяются через errors.Is: ErrInvalidPackage, ErrUnsupportedVersion, ErrLimitExceeded, ErrMediaUnavailable, ErrUnsafeURL; отмена сохраняет context.Canceled/context.DeadlineExceeded. Транспортных handlers на этапе 4 нет; внутренние причины ошибок нельзя напрямую выдавать в будущем API.

SHA-256 защищает от повреждения, но не подтверждает автора пакета. Подпись и шифрование не реализованы. Кодек не блокирует игру и не делает импорт; повторные импорты, сохранность QR на площадке и полный перенос проверяются на этапах 5–6.

Проверка: go test ./internal/services/game_package_service -count=1 и полный go test ./.... Тесты покрывают сохранение данных, автономное чтение, загрузку медиа, дедупликацию, потоковую обработку, отмену, очистку, лимиты и повреждённые пакеты.