Перейти к основному содержимому

Первый бот

Бот из этой главы заходит на сервер, доживает до игрового мира и остаётся там: отвечает на keep-alive, подтверждает телепорты, пишет в консоль, что с ним происходит. Играть он не умеет, но всё, что нужно для игры, у него уже под рукой.

Нужен сервер с online-mode=false: поход в Mojang за сессией библиотека не делает. Версия протокола задаётся целым числом: здесь это 772, то есть 1.21.8. Номер для своей версии - в таблице «Версия → протокол».

Полный текст примера лежит в репозитории: examples/MinimalBot.

Подключение

Клиент открывает TCP-соединение и дальше только гоняет пакеты.

const int Pv = 772;

await using var client = new MinecraftClient(new MinecraftClientOptions
{
Host = "127.0.0.1",
Port = 25565
});
await client.ConnectAsync();

Первые два пакета уходят сразу: рукопожатие с номером протокола и заявка на вход. Число 2 в конце рукопожатия - это переход в login.

await client.SendAsync(
new HandshakeSb.SetProtocolPacket(Pv, "127.0.0.1", 25565, 2), Pv);
await client.SendAsync(
new LoginSb.LoginStartPacket("McProtoBot", V764_Last: new(Guid.NewGuid())),
Pv);

Обработчик

Входящие пакеты разбирает наследник ClientboundHandler. Метод на каждый пакет уже объявлен, переопределяются только нужные.

sealed class Bot(MinecraftClient client, int pv) : ClientboundHandler
{
protected override ValueTask OnLoginCompress(LoginCb.LoginCompressPacket packet)
{
client.CompressionThreshold = packet.Threshold;
return default;
}

protected override async ValueTask OnKeepAlive(PlayCb.KeepAlivePacket packet)
{
await client.SendAsync(new PlaySb.KeepAlivePacket(packet.KeepAliveId), pv);
}
}

Пакет, для которого метод не переопределён, всё равно разбирается, а потом тихо отбрасывается: сервер шлёт много такого, что боту не нужно. В OnUnknown он попадает по другой причине - когда номер не разобрался в реестре для этой версии, фазы и направления. Это тоже нормальное состояние потока, а не ошибка.

Цикл чтения

Пакеты приходят потоком, цикл живёт в коде приложения.

var bot = new Bot(client, Pv);

using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };

try
{
await foreach (var packet in client.ReadPacketsAsync(cts.Token))
await bot.HandleAsync(in packet, Pv);
}
catch (OperationCanceledException)
{
// остановили сами, по Ctrl+C
}
catch (EndOfStreamException)
{
// сервер закрыл соединение
}

Без токена чтение ждёт следующий пакет столько, сколько сервер молчит. Оборвать цикл можно и с другой стороны - Abort или DisposeAsync из соседней задачи, - но токен для этого удобнее всего.

Конец сессии всегда прилетает исключением. Чистый разрыв - это EndOfStreamException; тихо перечисление не заканчивается.

Пакет живёт до следующего чтения: его данные - это окно в буфер, а не собственная копия. Разбирать надо сразу, тащить через await нельзя.

Фазы переключает бот

Библиотека сама не решает, когда login закончился. Это делает код в двух местах.

protected override async ValueTask OnLoginSuccess(LoginCb.LoginSuccessPacket packet)
{
await client.SendAsync(new LoginSb.LoginAcknowledgedPacket(), pv);
Phase = PacketPhase.Configuration;
}

protected override async ValueTask OnFinishConfiguration(ConfCb.FinishConfigurationPacket packet)
{
await client.SendAsync(new ConfSb.FinishConfigurationPacket(), pv);
Phase = PacketPhase.Play;
}

Сервер может вернуть игрока обратно в configuration прямо из игры: приходит StartConfiguration, бот подтверждает и переставляет Phase назад. Подробнее про весь путь - «Фазы протокола».

Три ответа, без которых бот не доживёт до мира

Сервер ждёт ответа на три пакета, и молчание в любом из них кончается тем, что бот повисает в configuration или получает кик после спавна.

Сразу после LoginAcknowledged уходят настройки клиента - язык, дальность прорисовки, видимые части скина:

await client.SendAsync(new ConfSb.ClientInformationPacket(
"en_us", 2, 0, true, 0x7F, 1, false, true,
V768_Last: new(ParticleStatus.All)), pv);

Дальше сервер присылает список известных ему наборов данных и ждёт, что клиент подтвердит тот же список:

protected override ValueTask OnSelectKnownPacks(ConfCb.SelectKnownPacksPacket packet)
=> client.SendAsync(new ConfSb.SelectKnownPacksPacket(packet.Packs), pv);

А в play каждый телепорт - включая первый, на спавне - нужно подтвердить его номером, иначе сервер решит, что клиент завис:

protected override ValueTask OnPlayerPosition(PlayCb.PlayerPositionPacket packet)
=> client.SendAsync(new PlaySb.TeleportConfirmPacket(packet.TeleportId), pv);

Шифрование

Даже на offline-сервере шифрование включается: приходит EncryptionRequestPacket, бот отвечает своим ключом и включает шифр. С 1.20.5 сервер шифрует поток и без проверки сессии - в протоколе это описано на странице Encryption, раздел History.

using var rsa = EncryptionHelpers.DecodeRSAPublicKey(packet.PublicKey)!;
var secret = EncryptionHelpers.GenerateAESPrivateKey();

await client.SendAsync(new LoginSb.EncryptionResponsePacket(
rsa.Encrypt(secret, false),
rsa.Encrypt(packet.VerifyToken, false)), pv);

client.EnableEncryption(secret);

Если сервер требует подтверждения сессии у Mojang, дальше пример не пойдёт.

Жизнь в play

После спавна бот в мире. Дальше от него нужны три вещи.

Первая - keep-alive. Сервер шлёт число и ждёт его обратно; молчащего клиента он считает зависшим и рвёт соединение. Ответ уже был выше, в разделе «Обработчик».

Вторая - чат. Сообщение игрока приходит пакетом PlayerChatPacket; тело сообщения лежит готовой строкой в PlainMessage, а имя отправителя и оформление - отдельными полями рядом.

protected override ValueTask OnPlayerChat(PlayCb.PlayerChatPacket packet)
{
Console.WriteLine(packet.V770_Last?.PlainMessage);
return default;
}

Служебные строки (вход, выход, ответы команд) приходят отдельным пакетом SystemChatPacket в метод OnSystemChat, и текст в нём лежит компонентом NBT. Библиотека отдаёт компонент как есть; собирать из него строку - работа приложения.

Своё сообщение уходит пакетом ChatMessagePacket. Кроме текста он несёт время, соль и подпись, а следом - подтверждение чужих сообщений: смещение Offset, ровно три байта Acknowledged и контрольную сумму Checksum. Сервер, который не проверяет подписи, принимает нули.

await client.SendAsync(new PlaySb.ChatMessagePacket(
"привет", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), 0, null,
V770_Last: new(0, new byte[3], 0)), pv);

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

await client.SendAsync(new PlaySb.PositionPacket(
x, y, z, V768_Last: new(new MovementFlags(true, false))), pv);

Дальше