Учимся находить лучшее для своего разбойника при помощи программирования. Также разбираемся, не водит ли нас программа «за нос».
Цель: научиться поэтапно моделировать нужную часть механики игры в «пробирке», получать нужные данные и делать выводы из них.
Что нужно: Python 3, среда для работы с кодом (у меня PyCharm).
В играх многие люди хотят выжать максимум из своих персонажей, а для этого нужно выбрать наиболее оптимальное сочетание экипировки, которой часто бывает много. Попробуем написать свой алгоритм для тестов различных сочетаний экипировки и сбора полученных данных.
Изначально я вдохновился игрой «World of Warcraft: Classic» (иконки взял оттуда), но в процессе сделал некоторые упрощения. Ссылка на весь проект в конце статьи.
Допустим, у нас есть персонаж класса Разбойник (Rogue). Нужно подобрать ему экипировку, в которой он будет наносить максимальный урон противнику. Нас интересуют вещи для слотов «оружие в правой руке» (4 шт.), «оружие в левой руке» (4 шт.), «перчатки» (2 шт.), «голова» (3 шт.), «грудь» (3 шт.), «ноги» (3 шт.), «ступни» (2 шт.). Будем надевать их различные комбинации на персонажа и симулировать бой. И если применить идею полного перебора (с чего мы и начнём), для оценки всех комбинаций придётся провести как минимум 4 * 4 * 2 * 3 * 3 * 3 * 2 = 1728 боёв.
Для более точной оценки лучших комбинаций нужно будет провести дополнительные бои.
Итак, уже на этом этапе схему проекта можем представить так:
Начнём с персонажа. У него есть такие характеристики, влияющие на наносимый урон и друг на друга:
На схеме ниже показаны базовые значения для нашего разбойника и как надевание предмета экипировки изменяет их:
Итак, пришло время начать писать код. Опишем то, что нам уже известно, в классе Rogue. Метод set_stats_without_equip будет восстанавливать состояние персонажа без экипировки, что пригодится при смене подборок. Методы calculate_critical_percent и calculate_glancing_percent в будущем будут вызываться лишь при необходимости, обновляя значения специфических характеристик.
Теперь нужно разобраться с экипировкой. Чтоб удобно перебирать все вещи, создавая их комбинации, решил для каждого типа экипировки создать отдельный словарь-константу: RIGHT_HANDS, LEFT_HANDS, GLOVES, HEADS, CHESTS, PANTS, BOOTS. В качестве значений в словарях хранятся такие кортежи:
Создадим отдельный файл для словарей с экипировкой. У меня таких файлов несколько с разными наборами.
Также добавим в наш класс методы wear_item (расчёт характеристик при надевании вещи) и unwear_all (снять все вещи).
Также сам факт сочетания некоторых вещей даёт дополнительные бонусы (в «World of Warcraft» это известно как «сет-бонус»). В моём абстрактном наборе такой бонус даётся от одновременного надевания мечей «Праворучный Страж Лесов» и «Леворучный Страж Лесов». Добавим это в код метода wear_item:
Теперь нашего разбойника нужно научить драться. Боем мы будем считать серию из 1000 ударов по противнику, который стоит к нам спиной и занят чем-то другим (типичная ситуация для «World of Warcraft»). Каждый удар, независимо от предшествующих, может быть:
Это будет определяться чередой проверок по такой схеме:
И для разбойника с базовыми значениями эта схема приобретает вид:
Запрограммируем эту механику, добавив метод do_attack в код нашего класса. Возвращать он будет кортеж из двух чисел: (исход атаки, нанесённый урон).
Добьёмся удобного отображения текущего состояния разбойника, чтобы в любой момент можно было проверить, что с ним происходит:
Теперь пора написать код, который обеспечит проведение боёв для всех возможных подборок экипировки. Для этого я последовательно вызываю функции по такой схеме:
Для сохранения логов использую две простенькие функции:
Итак, теперь осталось написать несколько строк, чтобы запустить сессию и сохранить её результаты. Также импортируем необходимые стандартные модули Python. Именно здесь можно определить, какой набор экипировки будет тестироваться. Для фанатов «World of Warcraft» я подобрал экипировку оттуда, но помните, что этот проект — лишь приближённая реконструкция механик оттуда.
На сессию из 1728 боёв у меня на ноутбуке уходит 5 секунд. Если установить LOG_EVERY_FIGHT = True, то в папке «fight_logs» будут появляться файлы с данными по каждому бою, но на сессию уже будет уходить 9 секунд. В любом случае в папке «session_logs» появится общий лог сессии:
Как видим, мало просто провести сессию, нужно ещё из сотен строк извлечь информацию о тех сочетаниях вещей, которые привели к наилучшим результатам. Для этого напишем ещё две функции. Общая идея в том, чтобы открыть полученный лог, создать список сумм урона по каждому бою, сортировать его и, например, для 5 наилучших ситуаций выписать названия используемых вещей.
Теперь в конце лога появляются строки с 5 подборками, показавшими наилучший результат:
Важно помнить, что в этом проекте есть элементы случайности: при определении типа удара с задействованием функции randint. Неоднократно проводя тесты, я заметил, что при повторении сессий с одними и теми же входными данными топ-5 подборок может различаться. Это не очень обрадовало, и взялся решать проблему.
Сначала сделал тестовый набор экипировки «obvious_strong», где и без тестов очевидно, какие подборки вещей здесь лучшие:
С таким набором будет 6 боёв (3 меча * 2 кинжала * 1 * 1 * 1 * 1 * 1). В топ-5 точно не должен попадать бой, где взят наихудший меч и наихудший кинжал. Ну и разумеется, на 1-м месте должна оказаться подборка с двумя сильнейшими клинками. Если поразмыслить, то для каждой подборки очевидно, на какое место она попадёт. Провёл тесты, ожидания оправдались.
Вот визуализация исхода одного из тестов этого набора:
Далее я снизил до минимума разрыв в размерах бонусов, даваемых этими клинками, с 5000, 800, 20 и 4000, 10 до 5, 4, 3 и 2, 1 соответственно (в проекте этот набор размещён в файле «equipment_obvious_weak.py»). И здесь вдруг на первое место вышла комбинация сильнейшего меча и наихудшего кинжала. Более того, в одном из тестов два наилучших оружия внезапно оказались на последнем месте:
Как это понимать? Ожидания в очевидно правильной расстановке подборок остались неизменными, но вот степень разницы между ними значительно снижена. И теперь случайности в ходе боёв (соотношение промахов и попаданий, критических и некритических ударов и т.д.) приобрели решающее значение.
Давайте проверим, насколько часто «дуэт топовых клинков» будет попадать не на первое место. Провёл 100 таких запусков (для этого я «строки запуска программы» обернул в цикл на 100 итераций и начал вести специальный лог для всей этой «суперсессии»). Вот визуализация результатов:
Итак, результаты в нашей программе не всегда устойчивы (34% «правильных» исходов против 66% «неправильных»).
Устойчивость результатов прямо пропорциональна разнице в значениях бонусов тестируемых вещей.
Учитывая то, что разница в размере бонусов хороших вещей, которые имеет смысл тестировать, бывает слабо ощутима (как в «World of Warcraft»), результаты таких тестов будут относительно неустойчивы (нестабильны, непостоянны и т.д.).
Стараемся мыслить логически.
Намечаем критерий успеха: «дуэт топовых клинков» должен попадать на первое место в 99% случаев.
Текущее положение: 34% таких случаев.
Если не менять принятый подход в принципе (переход от симуляции боёв для всех подборок к простому подсчёту характеристик, например), то остаётся изменить какой-то количественный параметр нашей модели.
Например:
Прежде всего мне показалась удачной идея с удлинением боя, ведь именно в этом месте и происходит всё то, что стало причиной удлинения этой статьи.
Протестирую гипотезу о том, что удлинение боя с 1 000 до 10 000 ударов позволит повысить устойчивость результатов (для этого нужно установить в константу ATTACKS_IN_FIGHT значение 10000). И это так:
Затем решил увеличить с 10 000 до 100 000 ударов, и это привело к стопроцентному успеху. После этого методом бинарного поиска начал подбирать количество ударов, которое выдало бы 99% удач, чтобы избавиться от чрезмерных вычислений. Остановился на 46 875.
Если моя оценка в 99% надёжности системы с такой длиной боя верна, тогда два теста подряд сводят вероятность ошибки к 0.01 * 0.01 = 0.0001.
И теперь, если запустить тест с боем в 46 875 ударов для набора экипировки на 1728 боёв, то это заберёт 233 секунды и вселит уверенность в то, что «Меч Мастера» рулит:
P.S. И это легко объяснить: два «Меча Мастера» позволяют добрать 10 единиц мастерства, что согласно заложенной механике исключает вероятность скользящих ударов, а это добавляет примерно 40% ударов, когда наносится Х или 2Х урона вместо 0.7Х.
Результат аналогичного теста для фанатов «WoW»:
Весь код проекта я выложил на гитхабе.
Уважаемое сообщество, буду рад обратной связи по этой теме.
UPD от 08.04.2020:
Благодаря комментариям Deerenaros, knotri и Griboks понял, что вместо симуляции тысяч боёв можно посчитать математическое ожидание для одного удара и на этой основе ранжировать экипировку. Выкинул из кода всё связанное с боями, вместо функции simulate_fight сделал calculate_expectation. На выходе результаты получаю такие же. Добавил в репозиторий получившийся код.
Цель: научиться поэтапно моделировать нужную часть механики игры в «пробирке», получать нужные данные и делать выводы из них.
Что нужно: Python 3, среда для работы с кодом (у меня PyCharm).
В играх многие люди хотят выжать максимум из своих персонажей, а для этого нужно выбрать наиболее оптимальное сочетание экипировки, которой часто бывает много. Попробуем написать свой алгоритм для тестов различных сочетаний экипировки и сбора полученных данных.
Изначально я вдохновился игрой «World of Warcraft: Classic» (иконки взял оттуда), но в процессе сделал некоторые упрощения. Ссылка на весь проект в конце статьи.
ЭТАП 1 — оцениваем область поиска
Допустим, у нас есть персонаж класса Разбойник (Rogue). Нужно подобрать ему экипировку, в которой он будет наносить максимальный урон противнику. Нас интересуют вещи для слотов «оружие в правой руке» (4 шт.), «оружие в левой руке» (4 шт.), «перчатки» (2 шт.), «голова» (3 шт.), «грудь» (3 шт.), «ноги» (3 шт.), «ступни» (2 шт.). Будем надевать их различные комбинации на персонажа и симулировать бой. И если применить идею полного перебора (с чего мы и начнём), для оценки всех комбинаций придётся провести как минимум 4 * 4 * 2 * 3 * 3 * 3 * 2 = 1728 боёв.
Для более точной оценки лучших комбинаций нужно будет провести дополнительные бои.
Итак, уже на этом этапе схему проекта можем представить так:
ЭТАП 2 — анализируем игровую механику
Начнём с персонажа. У него есть такие характеристики, влияющие на наносимый урон и друг на друга:
- сила атаки — конвертируется напрямую в урон, наносимый обычным ударом (1 к 1). Рассчитывается по формуле: очки силы атаки + очки силы + очки ловкости
- сила — +1 к силе атаки и всё (что поделать, таков геймдизайн)
- ловкость — +1 к силе атаки, а также каждые 20 единиц ловкости добавляют 1% критического шанса
- крит. шанс — шанс нанесения двойного урона, если удар не скользящий и не промах
- меткость — повышение шанса попасть по противнику
- мастерство — каждая единица мастерства снижает на 4% вероятность скользящего удара (которая изначально равна 40%, что означает, что 10 единиц мастерства полностью исключат вероятность скользящих ударов)
На схеме ниже показаны базовые значения для нашего разбойника и как надевание предмета экипировки изменяет их:
Итак, пришло время начать писать код. Опишем то, что нам уже известно, в классе Rogue. Метод set_stats_without_equip будет восстанавливать состояние персонажа без экипировки, что пригодится при смене подборок. Методы calculate_critical_percent и calculate_glancing_percent в будущем будут вызываться лишь при необходимости, обновляя значения специфических характеристик.
первые строки класса
class Rogue:
"""Класс описывает механику тестируемого персонажа."""
def __init__(self):
# БАЗОВЫЕ значения характеристик (они - точка отсчёта при смене экипировки):
self.basic_stat_agility = 50
self.basic_stat_power = 40
self.basic_stat_hit = 80
self.basic_stat_crit = 20
self.basic_stat_mastery = 0
# рассчитать текущие характеристики без вещей:
self.set_stats_without_equip()
# метод для расчёта текущих характеристик без вещей:
def set_stats_without_equip(self):
self.stat_agility = self.basic_stat_agility
self.stat_power = self.basic_stat_power
self.stat_attackpower = self.stat_agility + self.stat_power
self.stat_hit = self.basic_stat_hit
self.direct_crit_bonus = 0
self.calculate_critical_percent()
self.stat_mastery = self.basic_stat_mastery
self.calculate_glancing_percent()
# метод для расчёта шанса критического удара:
def calculate_critical_percent(self):
self.stat_crit = self.basic_stat_crit + self.direct_crit_bonus + self.stat_agility // 20
# метод для расчёта шанса скользящего удара:
def calculate_glancing_percent(self):
self.stat_glancing_percent = 40 - self.stat_mastery * 4
Теперь нужно разобраться с экипировкой. Чтоб удобно перебирать все вещи, создавая их комбинации, решил для каждого типа экипировки создать отдельный словарь-константу: RIGHT_HANDS, LEFT_HANDS, GLOVES, HEADS, CHESTS, PANTS, BOOTS. В качестве значений в словарях хранятся такие кортежи:
Создадим отдельный файл для словарей с экипировкой. У меня таких файлов несколько с разными наборами.
абстрактная экипировка для тестов
# Каждый элемент содержит кортеж, в котором значения означают следующее:
# 0 - название, 1 - атака, 2 - ловкость, 3 - сила, 4 - меткость, 5 - крит, 6 - мастерство
EQUIPMENT_COLLECTION = 'custom'
RIGHT_HANDS = dict()
RIGHT_HANDS[1] = ('Праворучный Страж Лесов', 50, 3, 0, 0, 0, 0)
RIGHT_HANDS[2] = ('Меч Ловкача', 40, 22, 0, 0, 0, 0)
RIGHT_HANDS[3] = ('Меч Точности', 40, 0, 0, 3, 0, 0)
RIGHT_HANDS[4] = ('Меч Мастера', 40, 0, 0, 0, 0, 5)
LEFT_HANDS = dict()
LEFT_HANDS[1] = ('Леворучный Страж Лесов', 35, 3, 0, 0, 0, 0)
LEFT_HANDS[2] = ('Меч Ловкача', 40, 22, 0, 0, 0, 0)
LEFT_HANDS[3] = ('Меч Точности', 40, 0, 0, 3, 0, 0)
LEFT_HANDS[4] = ('Меч Мастера', 40, 0, 0, 0, 0, 5)
GLOVES = dict()
GLOVES[1] = ('Перчатки Прыткости', 0, 12, 0, 2, 0, 0)
GLOVES[2] = ('Перчатки Всестороннести', 2, 2, 2, 1, 1, 0)
HEADS = dict()
HEADS[1] = ('Капюшон Ловкача', 0, 22, 0, 0, 0, 0)
HEADS[2] = ('Капюшон Жестокости', 0, 0, 0, 0, 2, 0)
HEADS[3] = ('Капюшон Концентрации', 0, 0, 0, 2, 0, 0)
CHESTS = dict()
CHESTS[1] = ('Мундир Ловкача', 0, 30, 0, 0, 0, 0)
CHESTS[2] = ('Мундир Жестокости', 0, 0, 0, 0, 3, 0)
CHESTS[3] = ('Мундир Концентрации', 0, 0, 0, 3, 0, 0)
PANTS = dict()
PANTS[1] = ('Поножи Ловкача', 0, 24, 0, 0, 0, 0)
PANTS[2] = ('Поножи Жестокости', 0, 0, 0, 0, 2, 0)
PANTS[3] = ('Поножи Концентрации', 0, 0, 0, 2, 0, 0)
BOOTS = dict()
BOOTS[1] = ('Сапоги Кровавой мести', 14, 0, 5, 0, 1, 0)
BOOTS[2] = ('Сапоги Тишины', 0, 18, 0, 1, 0, 0)
экипировка из World of Warcraft
# Каждый элемент содержит кортеж, в котором значения означают следующее:
# 0 - название, 1 - атака, 2 - ловкость, 3 - сила, 4 - меткость, 5 - крит, 6 - мастерство
EQUIPMENT_COLLECTION = "wow_classic_preraid"
RIGHT_HANDS = dict()
RIGHT_HANDS[1] = ('Священный заряд Дал\'Ренда', 81, 0, 4, 0, 1, 0)
RIGHT_HANDS[2] = ('Искатель сердец', 49, 0, 4, 0, 1, 0)
RIGHT_HANDS[3] = ('Песня Мираха', 57, 9, 9, 0, 0, 0)
LEFT_HANDS = dict()
LEFT_HANDS[1] = ('Племенной страж Дал\'Ренда', 52, 0, 0, 0, 0, 0)
LEFT_HANDS[2] = ('Искатель сердец', 49, 0, 4, 0, 1, 0)
LEFT_HANDS[3] = ('Песня Мираха', 57, 9, 9, 0, 0, 0)
GLOVES = dict()
GLOVES[1] = ('Рукавицы девизавра', 28, 0, 0, 0, 1, 0)
GLOVES[2] = ('Костяные когти Скула', 40, 0, 0, 0, 0, 0)
HEADS = dict()
HEADS[1] = ('Маска непрощённых', 0, 0, 0, 2, 1, 0)
HEADS[2] = ('Глаз Ренда', 0, 0, 13, 0, 2, 0)
HEADS[3] = ('Личина Ликана', 32, 0, 8, 0, 0, 0)
HEADS[4] = ('Призрачный покров', 0, 19, 12, 0, 0, 0)
CHESTS = dict()
CHESTS[1] = ('Трупная броня', 60, 8, 8, 0, 0, 0)
CHESTS[2] = ('Мундир Объятий ночи', 50, 5, 0, 0, 0, 0)
CHESTS[3] = ('Мундир бармена', 0, 11, 18, 0, 0, 0)
PANTS = dict()
PANTS[1] = ('Поножи девизавра', 46, 0, 0, 0, 1, 0)
PANTS[2] = ('Поножи Мастера клинка', 0, 5, 0, 1, 1, 0)
BOOTS = dict()
BOOTS[1] = ('Сапоги скорохода', 0, 21, 4, 0, 0, 0)
BOOTS[2] = ('Лапы Жуткого волка', 40, 0, 0, 0, 0, 0)
BOOTS[3] = ('Мангустовые сапоги', 0, 23, 0, 0, 0, 0)
добавим в конструктор класса Rogue строки по эквипу
...
# инициализация списка слотов экипировки, который должен содержать id надетых предметов:
# 0 - правая рука, 1 - левая рука, 2 - перчатки, 3 - голова, 4 - грудь, 5 - штаны, 6 - обувь
self.equipment_slots = [0] * 7
# инициализация списка слотов экипировки, который должен содержать названия надетых предметов:
self.equipment_names = ['ничего'] * 7
Также добавим в наш класс методы wear_item (расчёт характеристик при надевании вещи) и unwear_all (снять все вещи).
методы класса, отвечающие за работу с экипировкой
...
# метод для "снятия всей экипировки":
def unwear_all(self):
# сбросить id и названия экипировки на слотах персонажа:
for i in range(0, len(self.equipment_slots) ):
self.equipment_slots[i] = 0
self.equipment_names[i] = 'ничего'
self.set_stats_without_equip()
# метод для надевания экипировки:
def wear_item(self, slot, item_id, items_list):
# в слоте не должно быть экипировки, иначе пришлось бы снять её и отнять характеристики, которые она дала:
if self.equipment_slots[slot] == 0:
self.equipment_slots[slot] = item_id
self.equipment_names[slot] = items_list[item_id][0]
self.stat_agility += items_list[item_id][2]
self.stat_power += items_list[item_id][3]
# не забываем, что к силе атаки нужно добавить бонусы также от силы и ловкости:
self.stat_attackpower += items_list[item_id][1] + items_list[item_id][2] + items_list[item_id][3]
self.stat_hit += items_list[item_id][4]
self.direct_crit_bonus += items_list[item_id][5]
self.stat_mastery += items_list[item_id][6]
# если была добавлена ловкость ИЛИ прямой бонус к крит. шансу, пересчитать общий крит. шанс:
if items_list[item_id][2] != 0 or items_list[item_id][5] != 0:
self.calculate_critical_percent()
# если было добавлено мастерство, пересчитать вероятность скользящего удара:
if items_list[item_id][6] != 0:
self.calculate_glancing_percent()
Также сам факт сочетания некоторых вещей даёт дополнительные бонусы (в «World of Warcraft» это известно как «сет-бонус»). В моём абстрактном наборе такой бонус даётся от одновременного надевания мечей «Праворучный Страж Лесов» и «Леворучный Страж Лесов». Добавим это в код метода wear_item:
сет-бонусы в методе wear_item
...
# особый случай для набора экипировки "custom":
if EQUIPMENT_COLLECTION == 'custom':
# если в левую руку взят "Леворучный Страж Лесов" (id 1 для слота "левая рука"), а в правую взят "Праворучный Страж Лесов" (id 1 для слота "правая рука"), добавить дополнительно 2 к крит. шансу:
if slot == 1:
if self.equipment_slots[1] == 1 and self.equipment_slots[0] == 1:
self.direct_crit_bonus += 2
self.calculate_critical_percent()
print('Дары Лесов вместе...')
Теперь нашего разбойника нужно научить драться. Боем мы будем считать серию из 1000 ударов по противнику, который стоит к нам спиной и занят чем-то другим (типичная ситуация для «World of Warcraft»). Каждый удар, независимо от предшествующих, может быть:
- обычный — стандартный урон, в нашей модели эквивалентный характеристике «сила атаки» персонажа
- скользящий — 70% урона от обычного
- критический — двойной урон от обычного
- промах — 0 урона
Это будет определяться чередой проверок по такой схеме:
И для разбойника с базовыми значениями эта схема приобретает вид:
Запрограммируем эту механику, добавив метод do_attack в код нашего класса. Возвращать он будет кортеж из двух чисел: (исход атаки, нанесённый урон).
код для совершения атаки
...
# метод для проведения атаки:
def do_attack(self):
# попадание или промах:
event_hit = randint(1, 100)
# если промах:
if event_hit > self.stat_hit:
return 0, 0
# если попадание:
else:
# скользящий ли удар:
event_glancing = randint(1, 100)
# если больше или равно, тогда это скользящий удар,
# ведь когда у персонажа будет 10 очков "мастерства", тогда stat_glancing_percent будет равно 0,
# и возможность таких ударов будет исключена
if event_glancing <= self.stat_glancing_percent:
damage = floor(self.stat_attackpower * 0.7)
return 1, damage
# если удар НЕ скользящий:
else:
# критический ли удар:
event_crit = randint(1, 100)
# если удар НЕ критический:
if event_crit > self.stat_crit:
damage = self.stat_attackpower
return 2, damage
# если удар критический:
else:
damage = self.stat_attackpower * 2
return 3, damage
Добьёмся удобного отображения текущего состояния разбойника, чтобы в любой момент можно было проверить, что с ним происходит:
переопределяем магический метод __str__
...
# переопределяем "магический метод" для демонстрации текущего состояния персонажа:
def __str__(self):
# выписать в строку названия надетых предметов:
using_equipment_names = ''
for i in range(0, len(self.equipment_names) - 1 ):
using_equipment_names += self.equipment_names[i] + '", "'
using_equipment_names = '"' + using_equipment_names + self.equipment_names[-1] + '"'
# удобочитаемый текст:
description = 'Разбойник 60 уровня\n'
description += using_equipment_names + '\n'
description += 'сила атаки: ' + str(self.stat_attackpower) + ' ед.\n'
description += 'ловкость: ' + str(self.stat_agility) + ' ед.\n'
description += 'сила: ' + str(self.stat_power) + ' ед.\n'
description += 'меткость: ' + str(self.stat_hit) + '%\n'
description += 'крит. шанс: ' + str(self.stat_crit) + '%\n'
description += 'мастерство: ' + str(self.stat_mastery) + ' ед.\n'
description += 'шанс скольз. уд.: ' + str(self.stat_glancing_percent) + '%\n'
return description
ЭТАП 3 — подготовка к запуску
Теперь пора написать код, который обеспечит проведение боёв для всех возможных подборок экипировки. Для этого я последовательно вызываю функции по такой схеме:
- run_session — здесь реализованы вложенные циклы, перебирающие все требуемые словари с вещами и вызывающие для каждой комбинации следующую функцию; в конце будет сформирован текст отчёта и сохранён в лог сессии
- test_combination — сбрасываются все ранее надетые вещи и раз за разом вызывается метод wear_item, облачая персонажа в новый «прикид», после чего вызывается следующая функция
- simulate_fight — 1000 раз вызывается тот самый метод do_attack, ведётся учёт получаемых данных, при необходимости ведётся детальный лог для каждого боя
функции run_session, test_combination, simulate_fight
# провести сессию тестов набора экипировки:
def run_session(SESSION_LOG):
# счётчик боёв:
fight_number = 1
# здесь будут накапливаться отчёты:
all_fight_data = ''
# для каждого оружия в правой руке:
for new_righthand_id in RIGHT_HANDS:
# для каждого оружия в левой руке:
for new_lefthand_id in LEFT_HANDS:
# для каждых перчаток:
for new_gloves_id in GLOVES:
# для каждого шлема:
for new_head_id in HEADS:
# для каждого нагрудника:
for new_chest_id in CHESTS:
# для каждых штанов:
for new_pants_id in PANTS:
# для каждой обуви:
for new_boots_id in BOOTS:
new_fight_data = test_combination(fight_number,
new_righthand_id,
new_lefthand_id,
new_gloves_id,
new_head_id,
new_chest_id,
new_pants_id,
new_boots_id
)
all_fight_data += new_fight_data
fight_number += 1
# записать отчёты о всех боях этого сеанса:
save_data_to_file(SESSION_LOG, all_fight_data)
# подготовка к следующему бою и его запуск:
def test_combination(fight_number, righthand_id, lefthand_id, gloves_id, head_id, chest_id, pants_id, boots_id):
# сбросить все вещи:
my_rogue.unwear_all()
# взять оружие в правую руку:
my_rogue.wear_item(0, righthand_id, RIGHT_HANDS)
# взять оружие в левую руку:
my_rogue.wear_item(1, lefthand_id, LEFT_HANDS)
# надеть перчатки:
my_rogue.wear_item(2, gloves_id, GLOVES)
# надеть наголовник:
my_rogue.wear_item(3, head_id, HEADS)
# надеть нагрудник:
my_rogue.wear_item(4, chest_id, CHESTS)
# надеть поножи:
my_rogue.wear_item(5, pants_id, PANTS)
# надеть обувь:
my_rogue.wear_item(6, boots_id, BOOTS)
# выписать в строку "профайл" эквипа:
equipment_profile = str(righthand_id) + ',' + str(lefthand_id) + ',' + str(gloves_id) + \
',' + str(head_id) + ',' + str(chest_id) + ',' + str(pants_id) + \
',' + str(boots_id)
print(my_rogue)
print('equipment_profile =', equipment_profile)
# запуск боя с возвратом отчёта о её результатах:
return simulate_fight(equipment_profile, fight_number)
# симулировать бой, где будет нанесено attacks_total ударов по цели:
def simulate_fight(equipment_profile, fight_number):
global LOG_EVERY_FIGHT
# счётчики для статистики:
sum_of_attack_types = [0, 0, 0, 0]
sum_of_damage = 0
# если нужно, подготовиться к ведению лога боя:
if LOG_EVERY_FIGHT:
fight_log = ''
verdicts = {
0: 'пром.',
1: 'скол.',
2: 'обыч.',
3: 'крит.'
}
attacks = 0
global ATTACKS_IN_FIGHT
# вести бой, пока не будет достигнут максимум ударов:
while attacks < ATTACKS_IN_FIGHT:
# рассчитать кол-во урона:
damage_info = my_rogue.do_attack()
# счётчик нанесенного урона:
sum_of_damage += damage_info[1]
# счётчик типов атак:
sum_of_attack_types[ damage_info[0] ] += 1
attacks += 1
# если нужно, вести лог боя:
if LOG_EVERY_FIGHT:
fight_log += verdicts[ damage_info[0] ] + ' ' + str(damage_info[1]) + ' ' + str(sum_of_damage) + '\n'
# если нужно, сохранить лог:
if LOG_EVERY_FIGHT:
# название файла:
filename = 'fight_logs/log ' + str(fight_number) + '.txt'
save_data_to_file(filename, fight_log)
# подготовка всех данных для сохранения в строку:
attacks_statistic = ','.join(map(str, sum_of_attack_types))
fight_data = '#' + str(fight_number) + '/' + equipment_profile + '/' + str(sum_of_damage) + ',' + attacks_statistic + '\n'
return fight_data
Для сохранения логов использую две простенькие функции:
функции save_data, add_data
# записать результаты в указанный файл:
def save_data_to_file(filename, data):
with open(filename, 'w', encoding='utf8') as f:
print(data, file=f)
# добавить строки в указанный файл:
def append_data_to_file(filename, data):
with open(filename, 'a+', encoding='utf8') as f:
print(data, file=f)
Итак, теперь осталось написать несколько строк, чтобы запустить сессию и сохранить её результаты. Также импортируем необходимые стандартные модули Python. Именно здесь можно определить, какой набор экипировки будет тестироваться. Для фанатов «World of Warcraft» я подобрал экипировку оттуда, но помните, что этот проект — лишь приближённая реконструкция механик оттуда.
код, запускающий программу
# для расчёта вероятностей различных событий:
from random import randint
# все неровности будут округляться вниз:
from math import floor
# для работы со временем:
from datetime import datetime
from time import time
# импортировать другие файлы проекта:
from operations_with_files import *
# импортировать необходимый набор словарей с экипировкой:
from equipment_custom import *
#from equipment_wow_classic import *
#from equipment_obvious_strong import *
#from equipment_obvious_weak import *
# ЗАПУСК:
if __name__ == "__main__":
# из скольки ударов состоит бой:
ATTACKS_IN_FIGHT = 1000
# логировать ли каждый отдельный бой:
LOG_EVERY_FIGHT = False
# сгенерировать название лога тестовой сессии:
SESSION_LOG = 'session_logs/for ' + EQUIPMENT_COLLECTION + ' results ' + datetime.strftime(datetime.now(), '%Y-%m-%d_%H-%M-%S') + '.txt'
print('SESSION_LOG =', SESSION_LOG)
# создать персонажа:
my_rogue = Rogue()
# засечь время:
time_begin = time()
# запустить тестовую сессию:
run_session(SESSION_LOG)
# вычислить затраченное время:
time_session = time() - time_begin
duration_info = 'сессия длилась: ' + str( round(time_session, 2) ) + ' сек.'
print('\n' + duration_info)
append_data_to_file(SESSION_LOG, duration_info + '\n')
# проанализировать сессию, с выводом 5 самых лучших сочетаний экипировки:
top_sets_info = show_best_sets(SESSION_LOG, 5)
# записать отчёт о лучших результатах в тот же общий файл:
append_data_to_file(SESSION_LOG, top_sets_info)
else:
print('__name__ is not "__main__".')
На сессию из 1728 боёв у меня на ноутбуке уходит 5 секунд. Если установить LOG_EVERY_FIGHT = True, то в папке «fight_logs» будут появляться файлы с данными по каждому бою, но на сессию уже будет уходить 9 секунд. В любом случае в папке «session_logs» появится общий лог сессии:
первые 10 строк лога
#1/1,1,1,1,1,1,1/256932,170,324,346,160
#2/1,1,1,1,1,1,2/241339,186,350,331,133
#3/1,1,1,1,1,2,1/221632,191,325,355,129
#4/1,1,1,1,1,2,2/225359,183,320,361,136
#5/1,1,1,1,1,3,1/243872,122,344,384,150
#6/1,1,1,1,1,3,2/243398,114,348,394,144
#7/1,1,1,1,2,1,1/225342,170,336,349,145
#8/1,1,1,1,2,1,2/226414,173,346,322,159
#9/1,1,1,1,2,2,1/207862,172,322,348,158
#10/1,1,1,1,2,2,2/203492,186,335,319,160
Как видим, мало просто провести сессию, нужно ещё из сотен строк извлечь информацию о тех сочетаниях вещей, которые привели к наилучшим результатам. Для этого напишем ещё две функции. Общая идея в том, чтобы открыть полученный лог, создать список сумм урона по каждому бою, сортировать его и, например, для 5 наилучших ситуаций выписать названия используемых вещей.
функции для определения топ-экипировки
# вывести указанное количество комбинаций с максимальным уроном:
def show_best_sets(SESSION_LOG, number_of_sets):
# список для хранения всех результатов боя:
list_log = list()
# прочитать строки лога, выписав из них в список list_log кортежи,
# содержащие сумму нанесённого урона и используемый для этого профиль экипировки:
with open(SESSION_LOG, 'r', encoding='utf8') as f:
lines = f.readlines()
for line in lines:
try:
list_line = line.split('/')
list_fight = list_line[2].split(',')
list_log.append( ( int(list_fight[0]), list_line[1].split(',') ) )
except IndexError:
break
# сортировать список, чтобы лучшие результаты оказались в начале:
list_log.sort(reverse=True)
# сформировать удобочитаемый отчёт, перебрав number_of_sets кейсов в списке лучших результатов:
top_sets_info = ''
for i in range(0, number_of_sets):
current_case = list_log[i]
# перебрать список идентификаторов экипировки в текущем кейсе и выписать их названия:
clear_report = ''
equipment_names = ''
equip_group = 1
for equip_id in current_case[1]:
equipment_names += '\n' + get_equip_name(equip_id, equip_group)
equip_group += 1
line_for_clear_report = '\n#' + str(i+1) + ' - ' + str(current_case[0]) + ' урона нанесено с:' + equipment_names
clear_report += line_for_clear_report
print('\n', clear_report)
top_sets_info += clear_report + '\r'
return top_sets_info
# вывести название экипировки по id:
def get_equip_name(equip_id, equip_group):
equip_id = int(equip_id)
if equip_group == 1:
return RIGHT_HANDS[equip_id][0]
if equip_group == 2:
return LEFT_HANDS[equip_id][0]
if equip_group == 3:
return GLOVES[equip_id][0]
if equip_group == 4:
return HEADS[equip_id][0]
if equip_group == 5:
return CHESTS[equip_id][0]
if equip_group == 6:
return PANTS[equip_id][0]
if equip_group == 7:
return BOOTS[equip_id][0]
Теперь в конце лога появляются строки с 5 подборками, показавшими наилучший результат:
наконец удобочитаемые строки лога
сессия длилась: 4.89 сек.
#1 - 293959 урона нанесено с:
Меч Ловкача
Меч Мастера
Перчатки Прыткости
Капюшон Ловкача
Мундир Ловкача
Поножи Ловкача
Сапоги Кровавой мести
#2 - 293102 урона нанесено с:
Меч Ловкача
Меч Мастера
Перчатки Прыткости
Капюшон Ловкача
Мундир Ловкача
Поножи Ловкача
Сапоги Тишины
#3 - 290573 урона нанесено с:
Меч Мастера
Меч Мастера
Перчатки Прыткости
Капюшон Ловкача
Мундир Ловкача
Поножи Ловкача
Сапоги Кровавой мести
#4 - 287592 урона нанесено с:
Меч Мастера
Меч Мастера
Перчатки Прыткости
Капюшон Ловкача
Мундир Ловкача
Поножи Ловкача
Сапоги Тишины
#5 - 284929 урона нанесено с:
Меч Ловкача
Меч Мастера
Перчатки Всестороннести
Капюшон Ловкача
Мундир Ловкача
Поножи Ловкача
Сапоги Кровавой мести
ЭТАП 4 — оцениваем устойчивость результатов
Важно помнить, что в этом проекте есть элементы случайности: при определении типа удара с задействованием функции randint. Неоднократно проводя тесты, я заметил, что при повторении сессий с одними и теми же входными данными топ-5 подборок может различаться. Это не очень обрадовало, и взялся решать проблему.
Сначала сделал тестовый набор экипировки «obvious_strong», где и без тестов очевидно, какие подборки вещей здесь лучшие:
смотреть набор obvious_strong
EQUIPMENT_COLLECTION = 'obvious_strong'
RIGHT_HANDS = dict()
RIGHT_HANDS[1] = ('Сильнейший меч', 5000, 0, 0, 0, 0, 0)
RIGHT_HANDS[2] = ('Средний меч', 800, 0, 0, 0, 0, 0)
RIGHT_HANDS[3] = ('Наихудший меч', 20, 0, 0, 0, 0, 0)
LEFT_HANDS = dict()
LEFT_HANDS[1] = ('Сильнейший кинжал', 4000, 0, 0, 0, 0, 0)
LEFT_HANDS[2] = ('Наихудший кинжал', 10, 0, 0, 0, 0, 0)
GLOVES = dict()
GLOVES[1] = ('Безальтернативные перчатки', 1, 0, 0, 0, 0, 0)
HEADS = dict()
HEADS[1] = ('Безальтернативный шлем', 1, 0, 0, 0, 0, 0)
CHESTS = dict()
CHESTS[1] = ('Безальтернативный нагрудник', 1, 0, 0, 0, 0, 0)
PANTS = dict()
PANTS[1] = ('Безальтернативные поножи', 1, 0, 0, 0, 0, 0)
BOOTS = dict()
BOOTS[1] = ('Безальтернативные сапоги', 1, 0, 0, 0, 0, 0)
С таким набором будет 6 боёв (3 меча * 2 кинжала * 1 * 1 * 1 * 1 * 1). В топ-5 точно не должен попадать бой, где взят наихудший меч и наихудший кинжал. Ну и разумеется, на 1-м месте должна оказаться подборка с двумя сильнейшими клинками. Если поразмыслить, то для каждой подборки очевидно, на какое место она попадёт. Провёл тесты, ожидания оправдались.
Вот визуализация исхода одного из тестов этого набора:
Далее я снизил до минимума разрыв в размерах бонусов, даваемых этими клинками, с 5000, 800, 20 и 4000, 10 до 5, 4, 3 и 2, 1 соответственно (в проекте этот набор размещён в файле «equipment_obvious_weak.py»). И здесь вдруг на первое место вышла комбинация сильнейшего меча и наихудшего кинжала. Более того, в одном из тестов два наилучших оружия внезапно оказались на последнем месте:
Как это понимать? Ожидания в очевидно правильной расстановке подборок остались неизменными, но вот степень разницы между ними значительно снижена. И теперь случайности в ходе боёв (соотношение промахов и попаданий, критических и некритических ударов и т.д.) приобрели решающее значение.
Давайте проверим, насколько часто «дуэт топовых клинков» будет попадать не на первое место. Провёл 100 таких запусков (для этого я «строки запуска программы» обернул в цикл на 100 итераций и начал вести специальный лог для всей этой «суперсессии»). Вот визуализация результатов:
Итак, результаты в нашей программе не всегда устойчивы (34% «правильных» исходов против 66% «неправильных»).
Устойчивость результатов прямо пропорциональна разнице в значениях бонусов тестируемых вещей.
Учитывая то, что разница в размере бонусов хороших вещей, которые имеет смысл тестировать, бывает слабо ощутима (как в «World of Warcraft»), результаты таких тестов будут относительно неустойчивы (нестабильны, непостоянны и т.д.).
ЭТАП 5 — повышаем устойчивость результатов
Стараемся мыслить логически.
Намечаем критерий успеха: «дуэт топовых клинков» должен попадать на первое место в 99% случаев.
Текущее положение: 34% таких случаев.
Если не менять принятый подход в принципе (переход от симуляции боёв для всех подборок к простому подсчёту характеристик, например), то остаётся изменить какой-то количественный параметр нашей модели.
Например:
- проводить не по одному бою для каждой подборки, а по несколько, затем записывать в лог среднее арифметическое, отбрасывать наилучшее и наихудшее и т.д.
- проводить вообще по несколько тестовых сессий, а затем также брать «усреднённое»
- удлинить сам бой с 1000 ударов до некого значения, которого будет достаточно, чтобы для очень приближенных по суммарному бонусу подборок результаты стали справедливыми
Прежде всего мне показалась удачной идея с удлинением боя, ведь именно в этом месте и происходит всё то, что стало причиной удлинения этой статьи.
Протестирую гипотезу о том, что удлинение боя с 1 000 до 10 000 ударов позволит повысить устойчивость результатов (для этого нужно установить в константу ATTACKS_IN_FIGHT значение 10000). И это так:
Затем решил увеличить с 10 000 до 100 000 ударов, и это привело к стопроцентному успеху. После этого методом бинарного поиска начал подбирать количество ударов, которое выдало бы 99% удач, чтобы избавиться от чрезмерных вычислений. Остановился на 46 875.
Если моя оценка в 99% надёжности системы с такой длиной боя верна, тогда два теста подряд сводят вероятность ошибки к 0.01 * 0.01 = 0.0001.
И теперь, если запустить тест с боем в 46 875 ударов для набора экипировки на 1728 боёв, то это заберёт 233 секунды и вселит уверенность в то, что «Меч Мастера» рулит:
итоги 1728 боёв по 46 875 ударов
сессия длилась: 233.89 сек.
#1 - 13643508 урона нанесено с:
Меч Мастера
Меч Мастера
Перчатки Прыткости
Капюшон Ловкача
Мундир Ловкача
Поножи Ловкача
Сапоги Тишины
#2 - 13581310 урона нанесено с:
Меч Мастера
Меч Мастера
Перчатки Прыткости
Капюшон Ловкача
Мундир Ловкача
Поножи Ловкача
Сапоги Кровавой мести
#3 - 13494544 урона нанесено с:
Меч Ловкача
Меч Мастера
Перчатки Прыткости
Капюшон Ловкача
Мундир Ловкача
Поножи Ловкача
Сапоги Тишины
#4 - 13473820 урона нанесено с:
Меч Мастера
Меч Ловкача
Перчатки Прыткости
Капюшон Ловкача
Мундир Ловкача
Поножи Ловкача
Сапоги Кровавой мести
#5 - 13450956 урона нанесено с:
Меч Мастера
Меч Ловкача
Перчатки Прыткости
Капюшон Ловкача
Мундир Ловкача
Поножи Ловкача
Сапоги Тишины
P.S. И это легко объяснить: два «Меча Мастера» позволяют добрать 10 единиц мастерства, что согласно заложенной механике исключает вероятность скользящих ударов, а это добавляет примерно 40% ударов, когда наносится Х или 2Х урона вместо 0.7Х.
Результат аналогичного теста для фанатов «WoW»:
итоги 1296 боёв по 46 875 ударов (wow classic preraid)
сессия длилась: 174.58 сек.
#1 - 19950930 урона нанесено с:
Священный заряд Дал'Ренда
Племенной страж Дал'Ренда
Костяные когти Скула
Личина Ликана
Трупная броня
Поножи девизавра
Лапы Жуткого волка
#2 - 19830324 урона нанесено с:
Священный заряд Дал'Ренда
Племенной страж Дал'Ренда
Рукавицы девизавра
Личина Ликана
Трупная броня
Поножи девизавра
Лапы Жуткого волка
#3 - 19681971 урона нанесено с:
Священный заряд Дал'Ренда
Племенной страж Дал'Ренда
Костяные когти Скула
Призрачный покров
Трупная броня
Поножи девизавра
Лапы Жуткого волка
#4 - 19614600 урона нанесено с:
Священный заряд Дал'Ренда
Племенной страж Дал'Ренда
Рукавицы девизавра
Призрачный покров
Трупная броня
Поножи девизавра
Лапы Жуткого волка
#5 - 19474463 урона нанесено с:
Священный заряд Дал'Ренда
Племенной страж Дал'Ренда
Костяные когти Скула
Личина Ликана
Трупная броня
Поножи девизавра
Мангустовые сапоги
Итоги
- Очевидный недостаток этой модели — комбинаторный взрыв. Например, если добавить ещё одни перчатки к этому набору, то боёв уже потребуется 4 * 4 * 3 * 3 * 3 * 3 * 2 = 2592, т.е. на 33% больше. Примерно на столько же вырастут затраты времени.
- Но выход есть: за счёт того, что бои сессии не зависят друг от друга и от порядка их проведения, вычисления можно вести параллельно, а результаты сводить в общий лог по мере готовности.
- Разумеется, анализ результатов можно усовершенствовать: оценивать частоту появления вещей в верхней половине списка, за счёт этого вывести ТОП самих вещей и, как следствие, даже вывести ТОП характеристик.
Весь код проекта я выложил на гитхабе.
Уважаемое сообщество, буду рад обратной связи по этой теме.
UPD от 08.04.2020:
Благодаря комментариям Deerenaros, knotri и Griboks понял, что вместо симуляции тысяч боёв можно посчитать математическое ожидание для одного удара и на этой основе ранжировать экипировку. Выкинул из кода всё связанное с боями, вместо функции simulate_fight сделал calculate_expectation. На выходе результаты получаю такие же. Добавил в репозиторий получившийся код.