Pull to refresh

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

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

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

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

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

UPD от 08.04.2020:
Благодаря комментариям Deerenaros, knotri и Griboks понял, что вместо симуляции тысяч боёв можно посчитать математическое ожидание для одного удара и на этой основе ранжировать экипировку. Выкинул из кода всё связанное с боями, вместо функции simulate_fight сделал calculate_expectation. На выходе результаты получаю такие же. Добавил в репозиторий получившийся код.
Tags:pythonанализкомбинаторикаигрыRPGworld of warcraft
Hubs: Python Games and game consoles
Total votes 12: ↑12 and ↓0 +12
Views8.2K

Comments 11

Only those users with full accounts are able to leave comments. Log in, please.

Popular right now

Web-разработчик на Python
April 15, 202149,000 ₽OTUS
Game Design
April 15, 202160,500 ₽XYZ School
Python-разработчик с нуля
April 19, 202169,900 ₽Нетология
Python для работы с данными
April 26, 202133,800 ₽Нетология
Game Code
May 1, 202160,500 ₽XYZ School

Top of the last 24 hours