Обновления по api-go

Вернуться к API референсу

Я продолжаю миграцию с NodeJS на Golang реализацию, и вместе с этим реализую новые фичи. “Canary” версия клиентского мода уже направляет запросы на api.nlvl.quest вместо api.newlevelvr.ru, а я продолжаю придумывать себе задачи и решать их.

Не пугайтесь, пожалуйста, большого количества текста :)
Я постарался максимально детально рассказать, какие технологии и решения я использовал.
Если вы не готовы читать так много текста – в каждой категории есть TL;DR с видеодемонстрацией конкретной фичи.

1. Обновлятор

TL;DR

Этот механизм будет изменен в будущем, см п.3

Квесты в Майнкрафте представлены в виде zip-архива и json-файла. Архив template.zip содержит саму карту, которую мод распаковывает в папку сохранений игры при создании нового сохранения. Json-файл содержит информацию, необходимую для отображения квеста в меню выбора квестов в игре. Пример такого файла:

{
    "name": "newlevelvr.client.maps.arena",
    "type": "adventure",
    "version": 2
}

Поскольку созданием квестов будут заниматься другие люди, хотелось бы автоматизировать процесс выпуска обновлений карт, чтобы никому не пришлось вручную бегать по клубу и скачивать на каждый компьютер последние версии, была разработана программа для автоообновления. Каждая карта пакуется в еще один zip-архив и загружается на S3-подобное хранилище данных в такой схеме:

* newlevelvr_pack_xxxxxxx.zip - это пакет ресурсов для модификации строк, текстур и т.д., он всегда должен быть последней версии

После этого путем авторизованных запросов к API через специальную программу можно отслеживать обновления карт и ресурс-пака:

Хранилище: Yandex Object Storage
Доступ к хранилищу: Свое API
Бэкенд приложения: Wails
Фронтенд приложения: Svelte

2. .nlvl

TL;DR

Такой формат доставки карт (zip, содержащий json и zip) кажется на самым красивым и удобным. В связи с этим я решил паковать карты по-своему, используя tar.gz и вставкой метаданных о карте в поле Extra в формате protobuf:

enum MapType {
  SURVIVAL = 0;
  ADVENTURE = 1;
}

message Map {
  string name = 1;
  string gameVersion = 2;
  MapType type = 3;
  bool hasResourcePack = 4;
  int64 resourcePackSize = 5;
  repeated string env = 6;
}

В TarWriter сначала записывается файл resources.zip, если он есть, и после этого все остальные файлы. Этот архив содержать необходимые ресурсы для игры на данной карте (измененные текстуры, модели и так далее), записывается он первым для удобства извлечения конкретно ресурс-пака в дальнейшем (см. 3.2/GetResourcePack)

3. Оператор

TL;DR
Оператор - Мод
NewClient

GetResourcePack

Оператор - Интерфейс
EventListener

CreateGame, StartGame и StopGame

ConnectToConsole и SendCommand

EditPlayer, SendPlayerCommand и ConnectPlayer

Администраторы клуба, в который поставляются карты, часто сталкиваются с трудностями работы с майнкрафтом, из-за чего они иногда даже отказывают клиентам (“у нас нет майнкрафта”). Например, интегрированный сервер майнкрафта запускается под капотом игры при запуске мира, и при желании может быть открыт в локальную сеть, чтобы другие могли подключиться и играть в одном мире. И может быть такая ситуация, что человек, который хостит мир, больше не хочет играть, но остальные хотят. Из-за этого его приходится менять местами с кем-то еще, чтобы не выключать игру, что создает дополнительные проблемы.

Решение - запускать в локальной сети выделенный сервер и подключать всех игроков именно к нему. Программа-Оператор по задумке и будет выполнять эту функцию. Она будет доступна администратору клуба, который сможет удаленно запустить на одном из компьютеров выделенный сервер, а затем так же удаленно подключить к нему нужных игроков.

Система разделена на три части:

Далее в объяснении будет также использоваться термин “сервер”, подразумевающий либо выделенный сервер майнкрафта, либо интегрированный сервер майнкрафта, который создается при запуске мира в одиночной игре.

3.1. Оператор - Мод

TL;DR
NewClient

GetResourcePack

Мод общается с Оператором через gRPC сервис InGameService по следующему протоколу:

service InGameService {
  rpc NewClient(stream ClientData) returns (stream Response) {}
  rpc GetResourcePack(ResourcePackRequest) returns (stream ResourcePackMessage) {}
}

NewClient

TL;DR

Это что-то типа websocket-соединения, которое устанавливает Мод с Оператором при запуске игры. Это соединение удерживается на протяжении всей игры. Мод отсылает Оператору ClientData, содержащий информацию о своем персонаже и текущем IP-адресе, к которому подключен текущий клиент игры. В качестве идентификатора используется ник игрока.

message ClientData {
  string nick = 1;
  string clientName = 2;
  string skinId = 3;
  optional string ip = 4;
}

Оператор может отправлять Моду запросы на выполнение каких-то действий. Например, подключение к какому-то серверу (или отключение, если передать пустую строку), изменение персонажа или выполнение внутриигровой команды в чате:

message Response {
  oneof UnionResponse {
    ServerInfo joinServer = 1;
    ChangeCharacter changeCharacter = 2;
    ExecuteCommand executeCommand = 3;
  }
}

message ServerInfo {
  string ip = 1;
}

message ChangeCharacter {
  string clientName = 1;
  string skinId = 3;
}

message ExecuteCommand {
  string command = 1;
}

GetResourcePack

TL;DR

Сервер майнкрафта может затребовать у клиента загрузить ресурс-пак по указанной ссылке в начале игры. Мод перехватывает это требование, если ссылка содержит operator.nlvl.quest, достает оттуда ID карты и скачивает ресурс-пак у Оператора:

message ResourcePackRequest {
  string map = 1;
}

Оператор достает .nlvl файл карты, и если первым из TarReader извлекся файл resources.zip, он отправляет этот файл клиенту:

message ResourcePackMetadata {
  int64 size = 1;
}

message ResourcePackChunk {
  bytes data = 1;
}

message ResourcePackMessage {
  oneof ResourcePackData {
    ResourcePackMetadata metadata = 1;
    ResourcePackChunk chunk = 2;
  }
}

3.2. Оператор - Интерфейс

TL;DR
EventListener

CreateGame, StartGame и StopGame

ConnectToConsole и SendCommand

EditPlayer, SendPlayerCommand и ConnectPlayer

С Интерфейсом оператор будет общаться по отдельному сервису:

service OperatorService {

  rpc EventListener(google.protobuf.Empty) returns (stream SystemUpdate) {}
  
  rpc CreateGame(CreateGameRequest) returns (CreateGameResponse) {}
  rpc StartGame(SavePath) returns (CreateGameResponse) {}
  rpc StopGame(SavePath) returns (google.protobuf.Empty) {}
  
  rpc ConnectToConsole(SavePath) returns (stream ConsoleLine) {}
  rpc SendCommand(SendCommandRequest) returns (SendCommandResponse) {}
  
  rpc EditPlayer(EditPlayerRequest) returns (google.protobuf.Empty) {}
  rpc SendPlayerCommand(SendPlayerCommandRequest) returns (google.protobuf.Empty) {}
  rpc ConnectPlayer(ConnectPlayerRequest) returns (google.protobuf.Empty) {}
  
}

EventListener

TL;DR

Подписывается на получение обновлений системы. Обновления могут быть трех видов:

При подключении сразу же отправляет сообщения по всем подключенным к Оператору клиентам и запущенным серверам.

message SystemUpdate {
  oneof Update {
    ServerCreate serverCreate = 1;
    ClientData clientDataUpdate = 2;
    ClientDisconnect clientDisconnect = 3;
  }
}

message ServerCreate {
  string game = 1;
  string map = 2;
  string save = 3;
  string ip = 4;
}

message ClientData {
  string nick = 1;
  string clientName = 2;
  string skinId = 3;
  optional string ip = 4;
}

message ClientDisconnect {
  string nick = 1;
}

CreateGame, StartGame и StopGame

TL;DR

CreateGame – создает и запускает новый docker-контейнер И новый docker-volume, в созданный volume распаковывает содержимое .nlvl-файла карты по переданному ID, пропуская файл resources.zip, если он есть (этот файл передается Моду при запросе GetResourcePack). В ответ возвращается ID созданного volume и порт, на котором будет работать сервер

StartGame – создает и запускает новый docker-контейнер на основе volume с указанным ID. Распаковки данных из файла карты при этом не происходит, так как в volume уже есть распакованный мир.

StopGame – выключает сервер и удаляет контейнер, сохраняя при этом volume мира. Передается ID volume, на котором работает сервер.

message CreateGameRequest {
  string game = 1;
  string map = 2;
}

message CreateGameResponse {
  string saveId = 1;
  string port = 2;
}

message SavePath {
  string game = 1;
  string save = 2;
}

ConnectToConsole и SendCommand

TL;DR

ConnectToConsole – в ответ сервер отправляет появляющиеся в консоли сервера строки.

message ConsoleLine {
  string line = 1;
}

SendCommand – Оператор с помощью RCON-протокола отправляет внутрь сервера полученную игровую команду и возвращает ее ответ.

message SendCommandRequest {
  SavePath server = 1;
  string command = 2;
}

message SendCommandResponse {
  repeated string lines = 1;
}

EditPlayer, SendPlayerCommand и ConnectPlayer

TL;DR

Эти команды Оператор передает Моду в диалоге NewClient, используя соответствующее сообщение.

EditPlayer – изменяет имя игрока или его скин. Имя игрока отображается в игре рядом с его ником, чтобы клиенты могли ориентироваться друг среди друга (все имеют ник вида [название VR-клуба][номер игровой зоны]). Мод получает сообщение changeCharacter. После того, как Мод изменит персонажа, Оператор получит сообщение в NewClient, а тот перенаправит его во все активные EventListener

SendPlayerCommand – выполняет указанную игровую команду. Мод получает сообщение executeCommand. В отличие от SendCommand, команда выполняется от имени игрока, а не сервера. Отследить результат выполнения невозможно, поэтому метод ничего не возвращает.

ConnectPlayer – подключает игрока к серверу с указанным IP. Если не передать IP, Мод отключится от текущего сервера. Мод получает сообщение joinServer. После того, как Мод выполнит это действие, Оператор получит сообщение в NewClient, а тот перенаправит его во все активные EventListener

message EditPlayerRequest {
  string nick = 1;
  string clientName = 2;
  string skinId = 3;
}

message SendPlayerCommandRequest {
  string nick = 1;
  string command = 2;
}

message ConnectPlayerRequest {
  string nick = 1;
  optional string ip = 2;
}