Как на Python подобрать экипировку для игрового перса

от автора

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

image

Цель: научиться поэтапно моделировать нужную часть механики игры в «пробирке», получать нужные данные и делать выводы из них.

Что нужно: Python 3, среда для работы с кодом (у меня PyCharm).

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

Изначально я вдохновился игрой «World of Warcraft: Classic» (иконки взял оттуда), но в процессе сделал некоторые упрощения. Ссылка на весь проект в конце статьи.

ЭТАП 1 — оцениваем область поиска

Допустим, у нас есть персонаж класса Разбойник (Rogue). Нужно подобрать ему экипировку, в которой он будет наносить максимальный урон противнику. Нас интересуют вещи для слотов «оружие в правой руке» (4 шт.), «оружие в левой руке» (4 шт.), «перчатки» (2 шт.), «голова» (3 шт.), «грудь» (3 шт.), «ноги» (3 шт.), «ступни» (2 шт.). Будем надевать их различные комбинации на персонажа и симулировать бой. И если применить идею полного перебора (с чего мы и начнём), для оценки всех комбинаций придётся провести как минимум 4 * 4 * 2 * 3 * 3 * 3 * 2 = 1728 боёв.

Для более точной оценки лучших комбинаций нужно будет провести дополнительные бои.

Итак, уже на этом этапе схему проекта можем представить так:

image

ЭТАП 2 — анализируем игровую механику

Начнём с персонажа. У него есть такие характеристики, влияющие на наносимый урон и друг на друга:

  1. сила атаки — конвертируется напрямую в урон, наносимый обычным ударом (1 к 1). Рассчитывается по формуле: очки силы атаки + очки силы + очки ловкости
  2. сила — +1 к силе атаки и всё (что поделать, таков геймдизайн)
  3. ловкость — +1 к силе атаки, а также каждые 20 единиц ловкости добавляют 1% критического шанса
  4. крит. шанс — шанс нанесения двойного урона, если удар не скользящий и не промах
  5. меткость — повышение шанса попасть по противнику
  6. мастерство — каждая единица мастерства снижает на 4% вероятность скользящего удара (которая изначально равна 40%, что означает, что 10 единиц мастерства полностью исключат вероятность скользящих ударов)

На схеме ниже показаны базовые значения для нашего разбойника и как надевание предмета экипировки изменяет их:

image

Итак, пришло время начать писать код. Опишем то, что нам уже известно, в классе 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. В качестве значений в словарях хранятся такие кортежи:

image

Создадим отдельный файл для словарей с экипировкой. У меня таких файлов несколько с разными наборами.

абстрактная экипировка для тестов

# Каждый элемент содержит кортеж, в котором значения означают следующее: # 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 урона

Это будет определяться чередой проверок по такой схеме:

image

И для разбойника с базовыми значениями эта схема приобретает вид:

image

Запрограммируем эту механику, добавив метод 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 — подготовка к запуску

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

image

  1. run_session — здесь реализованы вложенные циклы, перебирающие все требуемые словари с вещами и вызывающие для каждой комбинации следующую функцию; в конце будет сформирован текст отчёта и сохранён в лог сессии
  2. test_combination — сбрасываются все ранее надетые вещи и раз за разом вызывается метод wear_item, облачая персонажа в новый «прикид», после чего вызывается следующая функция
  3. 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-м месте должна оказаться подборка с двумя сильнейшими клинками. Если поразмыслить, то для каждой подборки очевидно, на какое место она попадёт. Провёл тесты, ожидания оправдались.

Вот визуализация исхода одного из тестов этого набора:

image

Далее я снизил до минимума разрыв в размерах бонусов, даваемых этими клинками, с 5000, 800, 20 и 4000, 10 до 5, 4, 3 и 2, 1 соответственно (в проекте этот набор размещён в файле «equipment_obvious_weak.py»). И здесь вдруг на первое место вышла комбинация сильнейшего меча и наихудшего кинжала. Более того, в одном из тестов два наилучших оружия внезапно оказались на последнем месте:

image

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

Давайте проверим, насколько часто «дуэт топовых клинков» будет попадать не на первое место. Провёл 100 таких запусков (для этого я «строки запуска программы» обернул в цикл на 100 итераций и начал вести специальный лог для всей этой «суперсессии»). Вот визуализация результатов:

image

Итак, результаты в нашей программе не всегда устойчивы (34% «правильных» исходов против 66% «неправильных»).

Устойчивость результатов прямо пропорциональна разнице в значениях бонусов тестируемых вещей.

Учитывая то, что разница в размере бонусов хороших вещей, которые имеет смысл тестировать, бывает слабо ощутима (как в «World of Warcraft»), результаты таких тестов будут относительно неустойчивы (нестабильны, непостоянны и т.д.).

ЭТАП 5 — повышаем устойчивость результатов

Стараемся мыслить логически.

Намечаем критерий успеха: «дуэт топовых клинков» должен попадать на первое место в 99% случаев.

Текущее положение: 34% таких случаев.

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

Например:

  • проводить не по одному бою для каждой подборки, а по несколько, затем записывать в лог среднее арифметическое, отбрасывать наилучшее и наихудшее и т.д.
  • проводить вообще по несколько тестовых сессий, а затем также брать «усреднённое»
  • удлинить сам бой с 1000 ударов до некого значения, которого будет достаточно, чтобы для очень приближенных по суммарному бонусу подборок результаты стали справедливыми

Прежде всего мне показалась удачной идея с удлинением боя, ведь именно в этом месте и происходит всё то, что стало причиной удлинения этой статьи.

Протестирую гипотезу о том, что удлинение боя с 1 000 до 10 000 ударов позволит повысить устойчивость результатов (для этого нужно установить в константу ATTACKS_IN_FIGHT значение 10000). И это так:

image

Затем решил увеличить с 10 000 до 100 000 ударов, и это привело к стопроцентному успеху. После этого методом бинарного поиска начал подбирать количество ударов, которое выдало бы 99% удач, чтобы избавиться от чрезмерных вычислений. Остановился на 46 875.

image

Если моя оценка в 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 урона нанесено с: Священный заряд Дал'Ренда Племенной страж Дал'Ренда Костяные когти Скула Личина Ликана Трупная броня Поножи девизавра Мангустовые сапоги 

Итоги

  1. Очевидный недостаток этой модели — комбинаторный взрыв. Например, если добавить ещё одни перчатки к этому набору, то боёв уже потребуется 4 * 4 * 3 * 3 * 3 * 3 * 2 = 2592, т.е. на 33% больше. Примерно на столько же вырастут затраты времени.
  2. Но выход есть: за счёт того, что бои сессии не зависят друг от друга и от порядка их проведения, вычисления можно вести параллельно, а результаты сводить в общий лог по мере готовности.
  3. Разумеется, анализ результатов можно усовершенствовать: оценивать частоту появления вещей в верхней половине списка, за счёт этого вывести ТОП самих вещей и, как следствие, даже вывести ТОП характеристик.

Весь код проекта я выложил на гитхабе.

Уважаемое сообщество, буду рад обратной связи по этой теме.

ссылка на оригинал статьи https://habr.com/ru/post/495774/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *