Бой без единой картинки: сначала тесты
Первые бои Terminal Leave шли только в автотестах: симуляция, сервер боя, боты и реплеи заработали раньше, чем на экране появился первый боец.
Первый бой с настоящими сетевыми клиентами выглядел как два терминала. Два тестовых игрока подключились к серверу боя, отдали приказы, закрепили их, и сервер разыграл раунд на карте промзоны 24 на 24 клетки. Один из игроков получил в ответ пустой список событий: противник был дальше радиуса его обзора, а линию огня закрывал контейнер. Ни карты, ни бойцов на экране не было — только JSON. Но это пустое место и было главным результатом: туман войны работал.
Клиента у него ещё не было совсем.
Почему в таком порядке
Причин три. Первая — в устройстве игры. Бой в Terminal Leave считает только сервер. Клиент ничего не решает: он отправляет приказы и рисует то, что ему прислали. Значит, правила можно написать и проверить без клиента вообще, а клиент потом станет просто ещё одним потребителем уже работающего сервера.
Вторая причина практическая. Для клиента нужна графика — тайлы, спрайты бойцов, анимации, — а её на тот момент не было. Я решил не рисовать временные квадраты, а остановиться перед клиентом и делать то, что от графики не зависит. Не зависело от неё почти всё: симуляция, сервер боя, аккаунты, боты, реплеи.
Третья причина — во мне. По профессии я бэкенд-разработчик: серверы, данные, протоколы и тесты к ним — моя обычная работа, а клиент с графикой и анимациями был для меня новой территорией. Начать с того, что умеешь лучше всего, значило быстрее получить надёжное основание, на которое потом можно опереться, пока разбираешься с остальным.
К тому же правила — самая рискованная часть проекта. Одновременные события, обзор, туман войны, случайность, которая должна в точности повторяться. Если бы это не сложилось в работающую систему, никакая картинка бы не помогла.
Детерминизм с первой строки
Реплеи в Terminal Leave — это не запись видео, а повторный расчёт боя. Чтобы расчёт с теми же приказами давал тот же результат, симуляция с самого начала строилась по нескольким правилам. Время — целые миллисекунды от начала раунда. Проценты и углы — целые числа в тысячных долях, без плавающей точки там, где от неё зависит исход. Случайность — свой генератор с зерном боя, разбитый на именованные потоки, чтобы бросок одного бойца не сдвигал броски другого.
Эти правила проверяет не только тест, но и линтер. В коде симуляции запрещены Math.random, Date.now, new Date, таймеры, сетевые запросы и чтение переменных окружения. Нарушение не проходит проверку ещё до запуска тестов.
Сами тесты детерминизма простые. Один бой прогоняется 25 раз с одним зерном, и журналы событий должны совпасть байт в байт. Результат сравнивается с эталонным снимком, а сборка проверяет его на двух версиях Node.js — если расчёт где-то зависит от платформы, снимок разойдётся.
Одновременность
Самый показательный тест того времени — взаимное убийство. Два бойца стреляют друг в друга в одну и ту же миллисекунду, и оба должны погибнуть. В пошаговой игре так не бывает: кто ходит первым, тот и стреляет. В игре с одновременными ходами это обычный исход, и правила обязаны его допускать, а не решать по тому, чей приказ сервер прочитал раньше.
Рядом — тесты на очередь движения, на занятые клетки, на выстрел сквозь стену, на овервотч, прерывающий чужой забег.
Сервер боя
Сессию боя я написал так, чтобы она не знала о сокетах: исходящие сообщения уходят в переданный ей приёмник. Поэтому весь цикл — подключение, планирование, закрепление приказов, расчёт раунда, рассылка результатов — проверяется обычным тестом без сети. Отдельные тесты потом прогоняют то же самое через настоящие WebSocket-соединения.
Сервер не верит клиенту. Время действий, которое прислал клиент, отбрасывается и считается заново. Приказ чужому бойцу, путь сквозь стену, повторное закрепление получают отказ с кодом ошибки, и на каждый такой код есть тест.
Туман войны проверяется строже всего. Боец ходит за стеной; тот, кто ходил, видит в своём результате два шага, его противник — ни одного, а полный журнал на сервере содержит всё. Важно, что противнику не просто не рисуют эти шаги: их нет в данных, которые к нему пришли.
Боты
Боты появились до клиента, потому что первый бой новичка — против бота, а отключившегося игрока тоже кто-то должен вывести с поля. Бот отдаёт приказы через ту же проверку, что и игрок, и видит только то, что видят его бойцы. Если противника не видно, он идёт к его точке высадки — это знание есть у любого игрока.
Тесты ведут такой бой от начала до конца, на несколько раундов, и проверяют, что он заканчивается победой одной из сторон, а не зависает.
Реплеи и сквозной тест
В конце боя сервер сохраняет полный файл реплея: карту, правила, начальные отряды, зерно, приказы каждого раунда и результат. Тест загружает его, пересчитывает бой и сравнивает с оригиналом.
Последним появился сквозной тест, который заменил ручную проверку. Он поднимает основной API и сервер боя с настоящим HTTP и WebSocket. Два гостя встают в очередь, их сводят в бой, бойцы блокируются, один игрок сдаётся. Итог боя применяется к аккаунтам дважды, и тест проверяет, что во второй раз ничего не изменилось. Затем бойцы снова свободны, а счётчик боёв увеличился.
Что это дало
Графика пришла "на следующий день", и клиенту достался проверенный сервер. Ошибки первых часов были ошибками отображения: боец смотрит не туда, анимация идёт слишком быстро, объекты рисуются серыми квадратами, в браузере не загружается движок отрисовки. Правила при этом не менялись.
Но у такого порядка есть и цена. Тест проверяет, что правило выполнено, но не то, удобно ли по нему играть. Первый же взгляд на экран показал то, что тесты увидеть не могли: один приказ на раунд — это мало, боец добегает и стоит без дела. Так появились ходы из нескольких шагов, и правила пришлось менять уже под картинку.
Привычка осталась. Новая механика сначала появляется в симуляции вместе с тестами и только потом на экране. Сейчас в проекте больше двух тысяч автотестов, и большая их часть проверяет бой.
