ИгрыХабрахабр

[Из песочницы] Как на 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 datetimefrom 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. Разумеется, анализ результатов можно усовершенствовать: оценивать частоту появления вещей в верхней половине списка, за счёт этого вывести ТОП самих вещей и, как следствие, даже вывести ТОП характеристик.

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

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

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть