Почти год назад я рассказывал о платформе HighLoad.Fun, где можно посоревноваться в оптимизации кода, но не упомянул Bot-Games.Fun - платформу, где нужно написать своего AI бота для участия в играх. Основное отличие от других аналогичных платформ - код бота не надо загружать на сервер, его нужно запускать на своём железе, что открывает широчайшие возможности по используемым технологиям и затраченным ресурсам на просчёт следующего хода. А ещё все игры с открытым кодом, можно влиять на правила, улучшать плеер, воспроизводящий игры, можно довольно просто написать свою игру, как это сделать расскажу под катом, а заодно и про архитектуру проекта.
Идея
В классическом варианте участники загружают свой код на платформу и платформа формирует списки участников на игру, а потом запускает сервер и клиентов (ботов) для симуляции. Среди фатальных недостатков здесь я вижу ограничения по языкам программирования/библиотекам, это в принципе обходится с помощью Docker'а и открытого протокола, но самое главное, ресурсы железа сильно ограничены: мало ОЗУ, нет доступа к видеокарте, ... Мне хотелось сделать так, чтобы любой участник мог использовать неограниченное организаторами количество ресурсов приближая тепловую смерть вселенной, при этом карманного ДЦ у меня к сожалению нет, поэтому надо сделать так, чтобы участники сами запускали своих ботов на своём железе. В итоге схема выглядит так:
Клиенты (боты) подключаются к серверу и попадают в очередь, раз в N минут (в данный момент 10) боты разбиваются на группы и создаются игры. После завершения игр определяются победители и начисляются очки используя рейтинг ELO.
Боты взаимодействуют с сервером по JSONRPC подобному API. Документацию к каждой игре можно посмотреть в Swagger'е на сервере.
Архитектура
Работая над платформой, я поставил себе задачу сделать её такой, чтобы создание новой игры было максимально простым процессом, а вся рутина была скрыта под капотом платформы. Вторая важная часть задачи заключалась в том, чтобы любой участник мог запустить минисервер (Local Runner) на своём железе самостоятельно, например для запуска обучения нейронок, при этом код на сервере и в Local Runner'е должен быть одинаковым, включая плеер который показывает завершившиеся игры. После нескольких итераций я пришёл к следующей схеме:
Для каждой игры надо реализовать интерфейс Game
, подготовить хендлеры RPC и написать плеер, который должен отрисовать игру в браузере. Затем игра с помощью объекта GameManager
встраивается либо в общий сервер, либо в LocalRunner. GameManager
также требует реализацию интерфейсов Storage
- для сохранения хода игры и Scheduler
- для запуска игр, но про них думать не надо, нужно просто подставить готовые.
Все игры на платформе пошаговые, т.е. каждая игра имеет состояние - State
и набор действий - Actions
, используя которые участники меняют текущее состояние. Все игры разбиты на ходы - Ticks
. В играх есть как изменяемые объекты, так и статические. Для хранения статических объектов и констант есть Options
, он нужен чтобы уменьшить объём данных необходимых для сохранения истории игры, чтобы не хранить в каждом Tick
'е то, что не меняется, а State
сохраняется каждый ход. С точки зрения бота процесс игры выглядит так:
Далее на примере игры "Морской бой", я покажу как легко писать игры. Платформа и игры написаны на Go, а плеер - на TypeScript + WebPack + Three.js для графики. Все исходники можно найти на GitHub.
Реализация логики игры
Как я говорил выше, всё что нужно сделать, это реализовать интерфейс Game
:
type Game interface {
Init() (options proto.Message, state proto.Message, waitUsers uint8, gameData any)
CheckAction(tickInfo *TickInfo, action proto.Message) error
ApplyActions(tickInfo *TickInfo, actions []Action) *TickResult
SmartGuyTurn(tickInfo *TickInfo) proto.Message
}
Метод Init возвращает 4 переменные:
Options
- константы и статические объекты.State
- состояние игры на 0 ходу.Битовая маска игроков, от которых ожидается ход (бит возведён - ждём ход, нет - от игрока не текущем ходе ничего не требуется)
Любые данные, которые могут пригодиться для конкретного инстанса игры. В случае игры "Дроны", там возвращается объект
world
для симуляции физики с помощью библиотеки Box2d.
Метод CheckAction
проверяет, что переданное действие является валидным и может быть применено в будущем в методе ApplyActions
, который, в свою очередь, изменяет текущее состояние и проверяет, закончилась ли игра или нет.
Платформа имеет debug режим, который позволяет играть не на рейтинг со стандартным соперником - SmartGuy'ем без учёта таймаутов. С помощью метода SmartGuyTurn
реализуются действия дефолтного игрока.
Protobuf
Игра очень простая и никаких опций и статических объектов в ней нет, поэтому Options
оставляем пустым:
message Options {}
Игровое поле состоит из 2 матриц 10х10, в которых расположены корабли. Соответственно протофайл State
выглядит так:
message State {
repeated Cell field1 = 1;
repeated Cell field2 = 2;
}
enum Cell {
EMPTY = 0;
SHIP = 1;
MISSED = 2;
GOT = 3;
}
Действия (Action
) могут быть 3х видов:
ActionSkip
- ничего не делать.ActionSetup
- изначальная установка кораблей.ActionFire
- атака клетки.
В протофайле это выглядит так:
message Action {
oneof data {
ActionSkip skip = 1;
ActionSetup setup = 2;
ActionFire fire = 3;
}
}
message ActionSkip {}
message ActionSetup {
message Ship {
string coordinate = 1;
bool vertical = 2;
}
Ship shipL4N1 = 1;
Ship shipL3N1 = 2;
Ship shipL3N2 = 3;
Ship shipL2N1 = 4;
Ship shipL2N2 = 5;
Ship shipL2N3 = 6;
Ship shipL1N1 = 7;
Ship shipL1N2 = 8;
Ship shipL1N3 = 9;
Ship shipL1N4 = 10;
}
message ActionFire {
string coordinate = 1;
}
Ожидается, что координаты будут задаваться в формате буква-цифра, например A1
.
Реализация интерфейса Game
Метод Init
тривиален: создаём и заполняем Options
и State
, так как. игроков двое и от обоих ожидается ход, то возводим нулевой и первый биты, получаем 3 и возвращаем его. Никаких дополнительных данных хранить в инстансе игры не надо, поэтому четвёртым значением будет nil.
func (b Battleships) Init() (proto.Message, proto.Message, uint8, any) {
return &pb.Options{}, &pb.State{
Field1: generateRandomField(),
Field2: generateRandomField(),
}, 3, nil
}
В методе CheckAction
для каждого возможного действия проверяем что это действие в принципе доступно, а если требуются дополнительные параметры, то и их корректность:
func (b Battleships) CheckAction(tickInfo *manager.TickInfo, action proto.Message) error {
switch curAction := action.(*pb.Action).Data.(type) {
case *pb.Action_Skip:
if !GetActions(tickInfo)[ActionSkip] {
return manager.ErrInvalidAction
}
return nil
case *pb.Action_Setup:
return b.CheckActionSetup(tickInfo, curAction.Setup)
case *pb.Action_Fire:
return b.CheckActionFire(tickInfo, curAction.Fire)
default:
panic("invalid action")
}
}
...
func (Battleships) CheckActionFire(tickInfo *manager.TickInfo, fire *pb.ActionFire) error {
if !GetActions(tickInfo)[ActionFire] {
return manager.ErrInvalidAction
}
_, _, err := CoordinateToXY(fire.Coordinate)
return err
}
В методе ApplyActions
применяем действия на текущий State
и проверяем закончена игра или нет. Вся чёрная работа по обработке таймаутов от клиента, создание новых Tick'ов и сохранения их в БД, ..., лежит на GameManager
, внутри игры об этом беспокоиться не надо. Плюс валидность действий была проверена в предыдущем методе. В качестве результата надо вернуть указатель на структуру
type TickResult struct {
GameFinished bool
Winner uint8
NewState proto.Message
NextTurnPlayers uint8
}
GameFinished
равен true
, если игра завершена, Winner
- номер победившего игрока начиная с 1, в случае ничьей - 0, NewState
- обновлённое состояние, NextTurnPlayers
- маска игроков, от которых ожидается ход, алгоритм такой же как и в Init
. Ниже реализация:
func (b Battleships) ApplyActions(tickInfo *manager.TickInfo, actions []manager.Action) *manager.TickResult {
for _, action := range actions {
tickInfo.CurUid = action.Uid
switch curAction := action.Action.(*pb.Action).Data.(type) {
case *pb.Action_Skip:
// Do nothing
case *pb.Action_Setup:
tickInfo.State = b.DoActionSetup(tickInfo, curAction.Setup)
case *pb.Action_Fire:
tickInfo.State = b.DoActionFire(tickInfo, curAction.Fire)
default:
panic("invalid action")
}
}
res := &manager.TickResult{
NewState: tickInfo.State,
}
if finished, winner := isGameFinished(tickInfo); finished {
res.GameFinished = finished
res.Winner = winner
} else {
res.NextTurnPlayers = 3
}
return res
}
...
func (Battleships) DoActionFire(tickInfo *manager.TickInfo, fire *pb.ActionFire) proto.Message {
x, y, _ := CoordinateToXY(fire.Coordinate)
f := GetField(tickInfo, false)
switch f[y*10+x] {
case pb.Cell_EMPTY:
f[y*10+x] = pb.Cell_MISSED
case pb.Cell_SHIP:
f[y*10+x] = pb.Cell_GOT
}
return tickInfo.State
}
И последний метод SmartGuyTurn
. SmartGuy у нас будет не очень smart и будет стрелять в случайную доступную клетку:
func (Battleships) SmartGuyTurn(tickInfo *manager.TickInfo) proto.Message {
actions := GetActions(tickInfo)
if actions[ActionFire] {
var availableCoords []string
for y := 0; y < 10; y++ {
for x := 0; x < 10; x++ {
c := tickInfo.State.(*pb.State).Field1[y*10+x]
if c == pb.Cell_EMPTY || c == pb.Cell_SHIP {
availableCoords = append(availableCoords, fmt.Sprintf("%s%d", string(rune('A'+y)), x))
}
}
}
return &pb.Action{Data: &pb.Action_Fire{Fire: &pb.ActionFire{Coordinate: availableCoords[rand.Intn(len(availableCoords))]}}}
} else if actions[ActionSkip] {
return &pb.Action{Data: &pb.Action_Skip{}}
} else {
panic("no known actions")
}
}
Вот в принципе и всё, логика игры готова, теперь нужно сделать API.
API
Объект реализующий API должен соответствовать интерфейсу
type GameApi interface {
http.Handler
GetSwagger(ctx context.Context) *openapi.OpenApi
GetPlayerHandler() http.Handler
}
Для реализации API я использую библиотеку github.com/go-qbit/rpc, она позволяет легко описать JSONRPC подобный протокол и предоставляет swagger.json
из коробки, при это ничего не нужно описывать в комментариях и генерировать, плюс версионность методов и даже описание ошибок в Swagger'е. Конструктор выглядит так:
type BattleshipsRpc struct {
*rpc.Rpc
}
func New(gm *manager.GameManager) *BattleshipsRpc {
gameRpc := &BattleshipsRpc{rpc.New("github.com/bot-games/battleships/api/method", rpc.WithCors("*"))}
if err := gameRpc.RegisterMethods(
mJoin.New(gm),
mWaitTurn.New(gm),
mActionSkip.New(gm),
mActionSetup.New(gm),
mActionFire.New(gm),
); err != nil {
panic(err)
}
return gameRpc
}
Все методы я расписывать не буду, а в качестве примера разберу mWaitTurn
- ожидание хода. Реализация лежит в папке battleships/api/method/wait_turn
и состоит из 2 файлов,method.go
- конструктор и базовая информация о методе и v1.go
- реализация версии 1.
Все методы взаимодействуют с игрой через GameManager
, поэтому они нуждаются в ссылке на него, что отображено в структуре и конструкторе ниже
type Method struct {
gm *manager.GameManager
}
func New(gm *manager.GameManager) *Method {
return &Method{
gm: gm,
}
}
func (m *Method) Caption(context.Context) string {
return `Wait turn`
}
func (m *Method) Description(context.Context) string {
return `Call the method to wait your turn and get the game status`
}
Также каждый RPC-метод должен вернуть методы с коротким названием и описанием для Swagger'а, вся остальная информация автоматически возьмётся из реализации:
type reqV1 struct {
Token string `json:"token" desc:"User bot token from [profile](/profile)"`
GameId string `json:"game_id"`
}
type stateV1 struct {
TickId uint16 `json:"tick_id"`
Actions []string `json:"actions" desc:"Available actions"`
YourField []string `json:"your_field"`
OpponentField []string `json:"opponent_field"`
}
var errorsV1 struct {
InvalidToken rpc.ErrorFunc `desc:"Invalid token"`
InvalidGameId rpc.ErrorFunc `desc:"Invalid game ID"`
GameFinished rpc.ErrorFunc `desc:"The game has finished. The result is in the data field, can be one of **Draw**, **Win**, **Defeat**"`
}
func (m *Method) ErrorsV1() interface{} {
return &errorsV1
}
func (m *Method) V1(ctx context.Context, r *reqV1) (*stateV1, error) {
tickInfo, err := m.gm.WaitTurn(ctx, r.Token, r.GameId)
if err != nil {
errGameFinished := &manager.ErrEndOfGame{}
if errors.Is(err, manager.ErrInvalidToken) {
return nil, errorsV1.InvalidToken("Invalid token")
} else if errors.Is(err, manager.ErrInvalidGameId) {
return nil, errorsV1.InvalidGameId("Invalid game ID")
} else if errors.As(err, errGameFinished) {
var gameResult string
if errGameFinished.Winner == 0 {
gameResult = "Draw"
} else if errGameFinished.IsYou {
gameResult = "Win"
} else {
gameResult = "Defeat"
}
return nil, errorsV1.GameFinished("The game has finished", gameResult)
}
return nil, err
}
actionsMap := battleships.GetActions(tickInfo)
actions := make([]string, 0, len(actionsMap))
for action := range actionsMap {
actions = append(actions, action)
}
return &stateV1{
TickId: tickInfo.Id,
Actions: actions,
YourField: createField(tickInfo, true),
OpponentField: createField(tickInfo, false),
}, nil
}
Во входной (reqV1
) и выходной (stateV1
) структурах помимо JSON параметров можно передать дополнительно описание desc
, которое будет отображено в Swagger. Особое внимание следует обратить на структуру errorsV1
и метод ErrorsV1
, они позволяют описать возможные ошибки бизнеслогики, которые будут доступны клиентам в Swagger документации и они будут знать чего можно ждать от сервера. Если метод вернёт ошибку не из этого списка, то клиент получит 500 - Internal server error
.
Вся логика лежит в методе V1
и состоит из 3 частей:
Из
GameManager
получаем текущийState
игрыЕсли ошибка, то пытаемся превратить её в одну из объявленных для клиента.
Заполнение структур с одновременным скрыванием секретной информации о чужом поле.
Осталось написать плеер на JavaScript и дать к нему доступ по HTTP с помощью метода GetPlayerHandler
:
func (r *BattleshipsRpc) GetPlayerHandler() http.Handler {
return player.NewHTTPHandler()
}
Все статические файлы прилинковываются к бинарнику, для этого я использую свой старый генератор gostatic2lib, который я сделал ещё до появления go:embed
, но до сих пор выбираю его, так как он сжимает файлы и хранит их в сжатом виде, плюс сразу создаёт HTTP Handler для их раздачи уже в сжатом виде. bundle.js
лежит в папке ../player/dist, соответственно чтобы сгенерировать пакет player
, нужно выполнить команду gostatic2lib -path ../player/dist -package player -out ./player/dist.go
.
Игра готова, можно создать main.go для Local Runner'а:
package main
import (
"github.com/bot-games/battleships"
"github.com/bot-games/battleships/api"
manager "github.com/bot-games/game-manager"
"github.com/bot-games/localrunner"
"github.com/bot-games/localrunner/scheduler"
"github.com/bot-games/localrunner/storage"
)
func main() {
gameStorage := storage.New()
localrunner.Start(
manager.New(
"battleships", "Battleships",
battleships.Battleships{},
gameStorage, scheduler.New(),
func(m *manager.GameManager) manager.GameApi {
return api.New(m)
},
),
gameStorage,
)
}
После запуска по умолчанию сервер поднимается на порту :10000, где будут доступны документация и список игр, где можно их просмотреть.
Плеер
Изначально я начал делать плеер на WASM, но в процессе отказался от этой идеи в пользу работы с графикой из TypeScript, так как по факту код на WASM всё равно обращается к JS функциям браузера, но только нет удобных библиотек, по крайней мере для Go, возможно я плохо искал. В итоге пришёл к следующей схеме: страница с игрой создаёт глобальную функцию player(p)
, где p
- пакет, содержащий класс Player
, конструктор которого ожидает контейнер, в котором будет отрисована игра, и JSON с информацией об игре. В упрощённом виде выглядит так:
function player(p) {
new p.Player(
window.document.getElementById('player'),
{...JSON с информацией об игре...}
}
}
Затем страница с игрой загружает файл bundle.js
, который в свою очередь после загрузки должен вызвать функцию player
(схема JSONP). Таким образом bundle.js
получает полный контроль над страницей игры и может делать всё что угодно.
В качестве системы сборки я использовал WebPack, ключевые моменты его конфигурации ниже:
module.exports = {
entry: './src/index.ts',
module: {
rules: [
{
test: /\.(png|svg|jpg|gif|obj)$/,
loader: 'url-loader'
},
{
test: /\.json$/,
loader: 'json-loader',
type: 'javascript/auto',
},
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
}
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
output: {
clean: true,
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
library: 'player',
libraryTarget: 'jsonp',
}
};
Код лежит в файле src/index.ts
, на выходе ожидается bundle.js
в папке dist
, мы собираем JSONP библиотеку. Так как нам не известен путь до bundle.js, то мы не знаем путь, где могут лежать ресурсы (картинки, JSON'ы, шрифты, 3d объекты, ...), поэтому есть как минимум 2 решения этой проблемы:
Вынести ресурсы на внешний CDN.
Положить их в
bundle.js
.
Я выбрал второй путь, строки 5-13 говорят WebPack, что файлы с перечисленными расширениями должны быть внутри bundle.js
.
В package.json я описал 3 скрипта:
"scripts": {
"build": "webpack --mode production",
"serve": "webpack serve --mode development",
"proto": "pbjs -t static-module -w es6 -o src/proto/drones.js ../proto/drones/*.proto && pbts -o src/proto/drones.d.ts src/proto/drones.js"
},
build
собирает bundle.js
, serve
- позволяет автоматически обновлять результат после сохранения изменений кода, для просмотра в браузере в папке public
лежит index.html
, эмулирующий упрощённую страницу с игрой. Так как Options
, State
и Actions
приходят в виде протообъектов, то надо сгенерировать из протофайлов TypeScript библиотеку для их парсинга, за это отвечает скрипт proto
.
Теперь вся инфраструктура готова, можно заняться index.ts
. Для работы с графикой я использовал Three.js, но в принципе можно использовать всё что угодно. С помощью Three.js нужно создать сцену, камеру, освещение и объекты, загрузить необходимые ресурсы и на каждый requestAnimationFrame
с помощью рендера отрисовать сцену. В коде довольно много рутинных вещей и копировать его сюда не хочется, поэтому предлагаю интересующимся заглянуть напрямую в код. Получилось как в известном меме:
Заключение
Сделать клиента для бота - тривиальная задача, есть swagger.json, из него можно сгенерировать клиента практически для любого языка программирования или написать его руками, методов не так много и они как правило простые. Примеры можно найти в папке bot-example
каждой игры.
Если есть желание принять участие в создании игр или написать своего бота или просто интересна эта тема, приходите к нам в Телеграм, нас там пока немного, а играми занимаемся я и @Karloid. Он кстати ещё параллельно RTS пилит.