golang

Bot-Games.Fun — игровая платформа для AI

  • вторник, 13 июня 2023 г. в 00:00:18
https://habr.com/ru/articles/740276/

Почти год назад я рассказывал о платформе 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 переменные:

  1. Options - константы и статические объекты.

  2. State - состояние игры на 0 ходу.

  3. Битовая маска игроков, от которых ожидается ход (бит возведён - ждём ход, нет - от игрока не текущем ходе ничего не требуется)

  4. Любые данные, которые могут пригодиться для конкретного инстанса игры. В случае игры "Дроны", там возвращается объект 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х видов:

  1. ActionSkip - ничего не делать.

  2. ActionSetup - изначальная установка кораблей.

  3. 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 частей:

  1. Из GameManager получаем текущий State игры

  2. Если ошибка, то пытаемся превратить её в одну из объявленных для клиента.

  3. Заполнение структур с одновременным скрыванием секретной информации о чужом поле.

Осталось написать плеер на 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 решения этой проблемы:

  1. Вынести ресурсы на внешний CDN.

  2. Положить их в 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 пилит.