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

в 15:23, , рубрики: python, rpg, world of warcraft, анализ, игры, Игры и игровые приставки, комбинаторика

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

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://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js