Учимся находить лучшее для своего разбойника при помощи программирования. Также разбираемся, не водит ли нас программа «за нос».

Цель: научиться поэтапно моделировать нужную часть механики игры в «пробирке», получать нужные данные и делать выводы из них.
Что нужно: 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. На выходе результаты получаю такие же. Добавил в репозиторий получившийся код.
