Заставляем ботов бесконечно играть в карты. cards.. cards. Data Mining.. cards. Data Mining. gamedev.. cards. Data Mining. gamedev. games.. cards. Data Mining. gamedev. games. python.

Суть проблемы

Как-то раз я занимался любимым делом — баловался написанием мини-игр в стол. На этот раз у меня на повестке была карточная игра-клон Inscryption. Ну, ее бледная копия с размытыми целями и перспективами. Но было понятно, что кор-механика игры — это карточный бой, и он должен быть увлекательным и засасывающим игрока надолго.

Когда я только начинал всю эту движуху, придумать правила карточной игры казалось очень простым делом — сделать с десяток-другой интересных бредовых карт, наметить игровой стол со слотами для карт, придумать незамысловатые правила ведения боя. Что может быть сложного?

Cпойлер
Давайте все вместе вести счет, сколько раз в статье будет употреблено слово дисбалансный.

Когда я взялся за эту задачу и реализовал ее первым получившимся образом, очень расстроился — я получил дисбалансное, неинтересное поделие. Тогда я взялся за задачу еще раз и реализовал ее вторым попавшимся способом, но результат стал даже хуже. В общем, в очередной раз подтвердилась мудрость, что “нормально будет” только если “нормально делай”.

Тут сразу стоит оговориться, чего я хотел добиться в первую очередь. Карточная игра в Inscryption дисбалансная по своей сути. При прочих равных у игрока есть фора. И компенсация дисбаланса там происходит за счет стартовых условий и разницы в картах у игрока и противника. Но я понимал, что в таком дисбалансном хаосе хоть как-то управлять развитием карточной игры я не смогу — сначала я хотел добиться честной, равноправной, потной игры при равных условиях, а уже потом, имея на руках такой эталон, развивать и модифицировать как угодно, при необходимости усложняя положение либо игрока либо противника. Именно эту первоочередную задачу я завалил при попытке взять все нахрапом.

Так, в первой вариации игры я подумал, что если у обоих игроков будет одинаковая колода, а карты будут бить друг друга одновременно — каждая карта бьет противника напротив себя — то должно получиться честное, потное рубилово. Но вместо этого я получил ситуацию, когда на средней дистанции все карты просто аннигилируют друг друга, постоянно оставляя доску пустой. И это ни разу не интересно.

Тогда я подумал, окей, если в Inscryption сначала ходит игрок, а потом его противник, то я могу сделать так же — красть так красть. Но теперь первый походивший игрок имел преимущество, и это было тоже дисбалансно. Забавно, что такого же рода преимущество есть у белого игрока в шахматы, но там это преимущество уж очень несущественно и размазывается за кучей последующих ходов. А тут у нас 4-5 несчастных слота и десяток карт.

Идея

Я приуныл, ведь как сделать скучные вариации моей дисбалансной игры нормальными, я не знал. Рандомно тыкаться, хаотично менять правила игры и все время играть и тестировать это — звучало удручающе и очень скучно.

И тут я и смекнул, что тебе не придется самому играть в твою скучную игру, если ты заставишь играть в нее бота. Просто посадишь двух ботов и заставишь их играть друг с другом в сотни вариаций твоей игры до посинения, чтобы они смогли вычислить для тебя (и за тебя) эталонную комбинацию правил.

Идея интересная и крайне заманчивая. Всегда приятно, когда вся работа делается за тебя. Нюанс, правда, состоял в том, что чтобы работа делалась за тебя, ты должен проделать большую работу по автоматизации той самой работы, которая должна делаться за тебя. Но это уже так — нюансы, которыми не испугать того, кто уже загорелся идеей.

Формализуем задачу

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

Вот давайте сходу: боты отыграли тысячу партий игры с одними правилами, потом отыграли еще одну тысячу партий игры с немного видоизмененными правилами. Что именно мы будем подсчитывать для первой тысячи игр? А для второй? А даже если предположим, что мы что-то насчитали, как мы будем это сравнивать и решать, какой из вариантов лучше? То-то же — задача уже с порога не обещает быть простой.

Начнем писать хоть что-то

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

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

Давайте договоримся

Мне предстоит достаточно сложная задача: раскрыть вам суть проделанного, провести через все проблемы и полученные решения, но при этом не удариться чрезмерно в конкретику. Ведь кода много — он, конечно, прекрасен, но вываливать его весь на вас ни к чему.

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

Пишем игру

Чтобы ботам было во что играть, нужно описать правила игры.

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

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

  • Какие-то правила у игры хочется менять. Т.е. мы хотим, чтобы у игры было несколько вариаций. Я рассудил, что вариаций игры будет 2-3, и называться они будут Alpha, Beta, Gamma и т.д.

  • Раз у игры есть несколько вариаций, значит и разновидностей ботов будет столько же: Alpha-бот, Beta-бот, Gamma-бот и т.д. Каждый умеет играть в конкретную вариацию игры. Но хочется несколько усложнить задачу: хочется, чтобы, например, Alpha-бот был не один, а целый выводок: AlphaIdiot, AlphaNormie, AlphaStronk. В теории это даст нам возможность, варьируя сложность бота, смотреть, насколько существенно будет сказываться более сильный/слабый интеллект на успехе в самой игре. И на основе этих наблюдений тоже можно было бы делать какие-то выводы о качестве и балансе игры

  • То ради чего мы собственно собрались — параметры игры. Их-то мы и хотим автоматически вычислить. Мы надеемся, что игровые боты смогут подсказать нам такие игровые параметры для каждой вариации игры, что все они будут сбалансированными и честными (с нашей субъективной точки зрения, конечно). В моем случае параметрами будут такие вещи как: количество слотов для карт на игровой доске, количество карт в колоде игрока, состав колоды игрока, стартовое количество карт в руке игрока, стартовое количество спичек игрока, количество спичек в общаке.

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

class GameBase:
    black_player: PlayerBase
    white_player: PlayerBase
    
	table: Table

	...

	# methods

Разберем это небольшой кусочек кода практически дословно:

  • Название GameBase намекает, что это базовая версия игры. От нее будут наследоваться все AlphaGame, BetaGame, GammaGame и пр. В самом классе GameBase будут находиться методы, касающиеся неизменных правил игры

  • Аналогично с игроками: PlayerBase — родительский класс для всех наших GammaIdiot, AlphaNormie, BetaStronk. В наследниках в основном будут находиться свои версии ИИ-ходов. Например, у Idiot-версии метод make_move может просто положить рандомную карту из своей руки на случайный слот игрового стола. Этот же метод у Stronk-версии будет проводить тщательный анализ своих карт и карт противника и, руководствуясь какой-то тактикой, делать продуманный ход или вовсе пасовать

  • В table спрятаны все данные, сопряженные с данными игры: слоты, колоды, спички. Они нас сейчас не особо интересуют

Если вы давно хотели потренироваться в написании абстрактной фабрики, то сейчас самое время! Ведь задача создать конкретный объект игры, который владеет совместимыми с этой игрой ботами-игроками нужного уровня смышлености — это как раз то самое. Оставим эту задачу в качестве самостоятельного упражнения для заинтересовавшихся.

Код игры

Код любой из вариаций игр — AlphaGame, BetaGame, GammaGame и пр. — будет реализовываться по одной и той же схеме:

  • Наследуемся от GameBase

  • Реализовываем машину состояний конкретной игры

Со стейт-машиной любые правила игры становится писать легко и приятно. Вот, допустим, я решил написать AlphaGame. Сначала я своими словами описываю, какие у игры будут правила и состояния:

## Start conditions

- 2 cards in a hand
- 10 cards in a deck
- 5 matches
- unlimited matches bank
- 5 slots

## Game states

- WHITE_RESOURCE_TAKE. Options:
    - Card from deck to hand
    - Match from bank
    - Do nothing and skip
- WHITE_MOVE. Options:
    - Place a new card to a board by paying matches
    - Do nothing and skip
- BLACK_RESOURCE_TAKE. Mirrors WHITE_RESOURCE_TAKE
- BLACK_MOVE. Mirrors WHITE_MOVE
- WHITE_STRIKE. Sequence:
    - Choose attacker card and victim card
    - Attack
    - Counter-attack if victim survives
- BLACK_STRIKE. Mirrors WHITE_STRIKE

Все, имея на руках такое ТЗ я могу смело входить в режим зомбо-кодера и просто переписать вышеописанное с английского на питонячий. Сначала описываем состояния:

class State(Enum):
    WHITE_RESOURCE_TAKE = 0
    WHITE_MOVE = 1
    BLACK_RESOURCE_TAKE = 2
    BLACK_MOVE = 3
    WHITE_STRIKE = 4
    BLACK_STRIKE = 5
    GAME_OVER = 6

Будто все еще пишу на английском языке в простой описательной форме!

Потом пишем класс игры, которая по сути целиком является стейт-машиной. Без разного отвлекающего шума это выглядит как-то так:

class AlphaGame(GameBase):
    state: State

    def launch(self):
        self.state = State.WHITE_RESOURCE_TAKE
        super().launch()

    def update(self) -> bool: # false - game ended
        match self.state:
            case State.WHITE_RESOURCE_TAKE:
                self.white_resource_take()
            case State.WHITE_MOVE:
                self.white_move()
            case State.BLACK_RESOURCE_TAKE:
                self.black_resource_take()
            case State.BLACK_MOVE:
                self.black_move()
            case State.WHITE_STRIKE:
                self.white_strike()
            case State.BLACK_STRIKE:
                self.black_strike()
            case State.GAME_OVER:
                return False
            
        return True

А в базовом классе располагается крутилка сией стейт-машины:

class GameBase:
    def launch(self):
        while self.update():
            pass

Если вышли из цикла, значит игра завершилась. Собственно и все, остается только по-отдельности реализовать все эти self.white_resource_take(), self.white_move(), self.black_strike() и т.п., и ваша игра готова! В этих методах и содержится сам геймплейный код: ходы ботов-игроков, смена состояний стейт машины.

Чтобы было показательнее, давайте рассмотрим один такой метод: white_strike():

def white_strike(self):
	(attacker_slot, victim_slot) = self.white_player.strike()

	self._cards_combat(attacker_slot, victim_slot, Side.WHITE)
	self.change_state(State.BLACK_STRIKE)

Здесь мы как раз видим все виды действий:

  • Вызов логики бота в self.white_player.strike()

  • Геймплейные расчеты игры в _cards_combat

  • Смена состояния игры в self.change_state(State.BLACK_STRIKE)

Т.к. боты-игроки у нас могут быть разного типа, метод self.white_player.strike() будет выполнять разный код в зависимости от типа бота. Например, мой AlphaIdiotPlayer реализует этот метод так:

def strike(self) -> tuple[int, int]: # (attacker_slot, victim_slot)
	attacker_slot = self.table.slots.get_random_slot(self.side, SlotStatus.OCCUPIED)
	victim_slot = self.table.slots.get_random_slot(self.side.get_opposite(), SlotStatus.OCCUPIED)

	return (attacker_slot, victim_slot)

Тут мы видим, что бот располагает всеми данными игрового стола, и глядя на эти данные может принимать те или иные решения. В данном случае AlphaIdiot просто берет свою случайную карту и бьет ей случайную карту противника — не зря же он Idiot.

Метрики

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

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

  • Честная игра — это когда на длинной дистанции шансы выигрыша двух равносильных игроков сводятся к 50/50

  • Игра не должна быть слишком короткой, равно как и слишком длинной. В противном случае игра выходит скучной. Это означает, что количество раундов нескучной игры лежит в каком-то оптимальном диапазоне. Я предположил, что это в среднем 10 раундов

  • Если игра часто приводит к какой-то патовой ситуации или невозможности доиграть — это прям плохо, и так не надо

На этом мои идеи исчерпались, поэтому я решил, что набор моих метрик будет состоять из этих трех параметров, которые я облачил в цифры:

class GameOverResult(Enum):
    NONE = 0
    WHITE_WINS = 1
    BLACK_WINS = 2
    DRAW = 3
    INIFNITY = 4

@dataclass
class Metrics:
    rounds: int = 0
    result: GameOverResult = GameOverResult.NONE
    exausted: bool = False

По итогам сыгранной игры можно получить объект типа Metrics. Корневая задумка такова: я должен придумать себе эталонные метрики идеальной игры, получить метрики с конкретной игры и сопоставить их с эталонными. Так можно будет во-первых, понять, насколько конкретная игра близка к нарисованному нам идеалу, во-вторых, имея метрики можно сравнивать одну проведенную игру с другой.

Напомню, как выглядит запуск игры в коде:

class GameBase:
    def launch(self):
        while self.update():
            pass

То есть достаточно запустить game.launch(), и игра будет сыграна. В нужных местах в коде будет несложно расставить врезки, которые предназначены для подсчета наших метрик. Эту задачу мы оставим за рамками статьи, ведь посчитать количество пройденных раундов и зафиксировать результат игры — тривиальные действия. Что важно для нас — одна пройденная игра выдает нам один объект с метриками Metrics.

Массово играем в игру

Чтобы честно провести замеры игры с конкретными правилами и параметрами, я буду заставлять ботов играть в одну и ту же игру раз за разом. Скажем, 1000 одинаковых игр должна вырисовать вполне себе стабильную статистическую картину. По итогу проведения 1000 игр, мы получим 1000 объектов Metrics. Что с ними делать, я поначалу не совсем понимал. Поэтому поначалу решил просто собирать эти объекты в отдельную SoA-структуру:

@dataclass
class BunchMetrics:
    rounds: list[int] = field(default_factory=list[int])
    results: list[GameOverResult] = field(default_factory=list[GameOverResult])
    exausted: list[bool] = field(default_factory=list[bool])
    
    def add_metrics(self, metric: Metrics):
        self.rounds.append(metric.rounds)
        self.results.append(metric.result)
        self.exausted.append(metric.exausted)

Крутилка нашей игры — достаточно простой и лаконичный класс, просто приведу его базовое исполнение:

class GameRepeater:
    game: GameBase
    bunch_metrics: BunchMetrics
    execute_count: int

    def launch(self):
        for _ in range(self.execute_count):
            self.game.launch()
            self.bunch_metrics.add_metrics(self.game.get_metrics())
            self.game.reset()

Просто гоняем одну и ту же игру (внутри которой прописаны одни и те же боты) и собираем метрики.

Диаграммно это можно выразить так:

Заставляем ботов бесконечно играть в карты - 1

Позже вы поймете, зачем мне эта незамысловатая эта диаграмма.

Как усредниться

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

Disclaimer. Эта глава может оказаться стыдной одой о том, что, возможно, я получил себе проблемы от не знания библиотек Pandas и numpy. Но это неточно, отпишитесь в комментариях, как мой велосипед решается взрослыми людьми.

Итак, мы имеем BunchMetrics. Как превратить его в Metrics?

Первая очевидная мысль — тотально усреднить и получить один объект Metrics. Но этот подход сразу заходит в тупик. Посмотри на метрики снова:

@dataclass
class Metrics:
    rounds: int = 0
    result: GameOverResult = GameOverResult.NONE
    exausted: bool = False

Количество раундов усредняемо, но вот усреднить bool можно лишь очень условно. С GameOverResult же и вовсе беда. Как можно усреднить enum? Более того, даже если взять самый простой случай — количество раундов — если мы просто возьмем все значения и усредним, получив одно единственное число, мы потеряем нехилый пласт информации. Смотрите:

  • Что если лучше смотреть не на среднее, а на медиану?

  • Что если медиана сильно разнится со средним? Может быть это тоже может что-то подсказать?

  • Что если две игры из тысячи вылились в 200+ раундов?

  • Что если десять игр из тысячи сыгрались в один раунд?

Даже неважно, какие выводы можно сделать из таких ситуаций — это уже вопрос на потом — главное, что есть стойкое ощущение: хочется анализировать все эти данные разом. Я пришел к следующей структуре:

@dataclass
class DataRange:
    min: float = 0.0
    max: float = 0.0
    average: float = 0.0
    median: float = 0.0

И это выглядит как движение в верном направлении:

  • Данные из BunchMetrics легко превратить в DataRange для каждой из метрик

  • DataRange предоставляют нам широкий простор для анализа происходящего

Есть, правда, одна проблема: есть поле, которое не вписывается в концепцию DataRange:

results: list[GameOverResult] = field(default_factory=list[GameOverResult])

Проблема: когда я буду диктовать идеальные метрики, я хочу сказать что-то в духе:

Мое желаемое идеальное распределение результатов игры следующее: белый игрок выигрывает с вероятностью в 49.75%, черный игрок выигрывает с вероятностью в 49.75%, ничья — 0.5%, остальные исходы – 0%

DataRange никак не подходит, нужно что-то другое. Хорошо, что когда проговариваешь идею словами, она часто ложится на код почти “как есть”, поэтому довольно быстро я родил то, что описал выше:

@dataclass
class ProbabilityTable:
    table: dict[int, float] = field(default_factory=dict[int, float]) # key -> probability proportion
    
    def inc_value(self, key: int, value: float):
        if key not in self.table:
            self.table[key] = 0.0
        self.table[key] += value

    def normalize(self):
        sum = 0.0
        for val in self.table.values():
            sum += val
        
        for key in self.table:
            self.table[key] /= sum
        self.normalized = True

    def get_probability(self, key: int) -> float:
        if not self.normalized:
            self.normalize()
        
        return self.table[key]

Это таблица хранит нормализованные значения вероятностей для каждого из исходов (для нормализации есть свой метод), и как раз она идеально описывает то, что к чему я стремился.

Теперь мы в состоянии ввести новую убер-сущность. Знакомьтесь — AveragedMetrics:

@dataclass
class AveragedMetrics:
    rounds: DataRange = field(default_factory=DataRange)
    result: ProbabilityTable = field(default_factory=ProbabilityTable) # [GameOverResult -> float]
    exausted: DataRange = field(default_factory=DataRange)

А класс BunchMetrics обзавелся новым методом, который умеет порождать усредненные данные:

@dataclass
class BunchMetrics:
	...    
    def get_average(self) -> 'AveragedMetrics':
        res = AveragedMetrics()

        res.rounds = DataRange.MakeFromList(self.rounds)
        res.result = ProbabilityTable.MakeFromList(self.results)
        res.exausted = DataRange.MakeFromList(self.exausted)

        return res

Методы DataRange.MakeFromList и ProbabilityTable.MakeFromList не представляют из себя никакого ракетостроения, поэтому их реализацию мы опустим.

Ну и после всех этих махинаций с данными я пришел к еще одному правильному выводу: идеальные метрики так же стоит представлять в виде класса AveragedMetrics. Например:

ideal_metrics = AveragedMetrics(
	rounds = DataRange(min=5, max=15, average=10, median=10),
	result = ProbabilityTable(
		table = {
			GameOverResult.BLACK_WINS: 0.4975,
			GameOverResult.WHITE_WINS: 0.4975,
			GameOverResult.DRAW: 0.005,
		},
	),
	exausted = DataRange(min=0, max=0, average=0, median=0),
)

Какие выгоды:

  • Во-первых только так вы и сможете нормально описать, что вы хотите от вероятностей исходов игры — через таблицу вероятностей. Через класс Metrics такие данные выразить просто невозможно

  • Вы можете задавать не только идеальное среднее или медианное значение, но и желаемое минимальное и максимальное, что так же дает простор для более гранулярной настройки

Что дальше

А что дальше, вы узнаете в следующей статье! Нда, вот так вот. Что вы узнаете в следующем выпуске:

  • Мы будем комбинаторно перебирать правила игры и выбивать метрики

  • Мы научимся сравнивать две AveragedMetrics. Да, если вы призадумаетесь, то это ни разу не просто. Почему? А подумайте, как сравнить две почти похожие метрики AveragedMetrics и как выразить результат такого сравнения

  • По метрикам определим ту самую игру с идеальными параметрами

  • Построим бесконечный майнер лучших правил игры из лучших через генерацию и подмену части python-кода (шок-контент)

  • Наконец-то сделаем игру, которая будет не дисбалансной

Автор: AskePit

Источник

Рейтинг@Mail.ru
Rambler's Top100