Я научу вас неправильно играть в Hearts of iron. Оптимизация довоенной экономики: часть 1

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

Теперь, когда мы определились с объектом оптимизации, нам важно осознать что оптимизация это дело серьезное и ответственное, мы никак не можем полагаться на чей либо опыт или мнение. Нам необходимо провести собственные исследования! Краеугольным камнем в этом вопросе является проверяемость результата оптимизации. Кто-то мог бы подумать что проверить результаты можно в игре, но этот процесс занимает уйму времени (да и не для того мы с вами здесь собрались чтобы играть в игру). Нет, наш путь ясен как день, это путь обратной разработки!

Формулы по которым работает внутриигровая экономика известны и относительно хорошо задокументированы, что весьма удачно (хотя чтобы протестировать нашу симуляцию все таки придется собрать некоторые данные в игре). Разумеется, на момент написания статьи код уже готов. Что же, приступим к разбору игровых механик и их реализации в python:

0. Общая структура

Начнем с описания общей структуры программы (которую можно найти здесь). Основа всей программы это два класса: Country и Region. Экземпляр Country содержит данные о государстве, его бонусах и количестве фабрик, экземпляры Region содержат данные о регионах и проводят вычисления результатов строительства и изменений контроля (если провинция не национальная). Классы Law, Order и Event носят больше вспомогательные функции.

Пояснения по вспомогательным классам.

Экземпляры Law хранят бонусы от законов (призыв, торговля, мобилизация экономики).

Order просто для очереди строительства.

Event преобразует текст в соответствующие инструкции (просто вызывает методы всё того же класса Country.

1. Строительство

Строительство работает достаточно просто все доступные фабрики строят по 5 * бонус_строительства единиц * бонус_инфраструктуры_региона, но не более 15 фабрик на регион. У каждого региона есть предел количества зданий. Всё это реализуется достаточно просто.

Деление доступных фабрик по 15 штук в классе Country

        while (
                free_factories > 0 and
                queue_position < (len(self.queue)-1)
        ):
            queue_position += 1
            if free_factories > 15:
                factories_for_region = 15
                free_factories += -15
            else:
                factories_for_region = free_factories
                free_factories = 0
            target_region_id = self.queue[queue_position].target_region_id
            done = self.regions[target_region_id].construct(
                factories_for_region,
                self.queue[queue_position].building_type,
                civil_constr_bonus=civil_constr_bonus,
                mil_constr_bonus=mil_constr_bonus,
                inf_constr_bonus=inf_constr_bonus,
            )

Код строительства и проверки наличия свободных ячеек под здания в классе Region

    def construct(self, factories, type_of_building,
                  civil_constr_bonus=0,
                  mil_constr_bonus=0,
                  inf_constr_bonus=0):
        construction_complete = False
        if self.is_on_construction_limit(type_of_building):
            raise Exception(
                f"Нельзя построить больше {type_of_building} "
                f"в регионе "
                f"{self.name}."
            )
        if type_of_building == MILITARY_BUILDING:
            self.mil_constr_progress += (
                factories * FACTORY_OUTPUT *
                (INFRASTRUCTURE_BONUS * self.infrastructure + 1) *
                (mil_constr_bonus + 1)
            )
            if self.mil_constr_progress > MILITARY_FACTORY_COST:
                self.military_factories += 1
                self.mil_constr_progress -= MILITARY_FACTORY_COST
                construction_complete = True
        elif type_of_building == CIVIL_BUILDING:
            self.civil_constr_progress += (
                    factories * FACTORY_OUTPUT *
                    (INFRASTRUCTURE_BONUS * self.infrastructure + 1) *
                    (civil_constr_bonus + 1)
            )
            if self.civil_constr_progress > FACTORY_COST:
                self.factories += 1
                self.civil_constr_progress -= FACTORY_COST
                construction_complete = True
        elif type_of_building == INF_BUILDING:
            self.inf_constr_progress += (
                    factories * FACTORY_OUTPUT *
                    (INFRASTRUCTURE_BONUS * self.infrastructure + 1) *
                    (inf_constr_bonus + 1)
            )
            if self.inf_constr_progress > INFRASTRUCTURE_COST:
                self.infrastructure += 1
                self.inf_constr_progress -= INFRASTRUCTURE_COST
                construction_complete = True
        else:
            raise Exception("Некорректный тип здания для постройки")
        return construction_complete
    def is_on_construction_limit(self, type_of_building):
        if (
                type_of_building == MILITARY_BUILDING or
                type_of_building == CIVIL_BUILDING
        ):
            if (
                    (self.factories + self.military_factories +
                     self.shipyards + self.fuel_silo + self.synth_oil
                     ) >= self.factories_limit
            ):
                return True
            else:
                return False
        elif type_of_building == INF_BUILDING:
            if self.infrastructure >= self.infrastructure_limit:
                return True
            else:
                return False
        else:
            raise Exception(
                f"Не найден лимит для здания "
                f"{type_of_building} в регионе "
                f"{self.name}")

2. Доступность фабрик

Этот пункт плавно вытекает из предыдущего. В hoi4 есть механика товаров народного потребления, фактически она заключается просто в том что часть фабрик вам не доступна для строительства. Количество забираемых фабрик рассчитывается очень просто:

фабрики_на_тнп = (фабрики+фабрики_от_торговли+военные_заводы)*коэффициент_тнп

Но это еще не всё. Вы получаете не все фабрики и заводы расположенные в не национальных провинциях. При гражданской администрации формула доли получаемого производства рассчитывается по формуле:

доля = 25% + 65%*контроль + 10%(если контроль>40%)

Расчет доступных фабрик в классе Country

    def _calculate_factories(self):
        civil_fact = 0
        mil_fact = 0
        shipyards = 0
        for region in self.regions:
            if self.tag in region.cores:
                civil_fact += region.factories
                mil_fact += region.military_factories
                shipyards += region.shipyards
            else:
                civil_fact += (
                    region.factories * region.get_compliance_modifier()
                )
                mil_fact += (
                    region.military_factories * 
                  region.get_compliance_modifier()
                )
                shipyards += (
                    region.shipyards * region.get_compliance_modifier()
                )
        # Округляем вниз
        civil_fact, mil_fact, shipyards = (
            int(civil_fact), int(mil_fact), int(shipyards))
        self.factories = civil_fact  # Все фабрики государства
        civil_fact += self.factories_from_trade  # Добавляем торговлю
        self.consumer_goods = self._get_consumer_goods()
        self.factories_for_consumers = floor(
                (civil_fact + mil_fact) * self._get_consumer_goods()
        )
        factories_available = (
            civil_fact - self.factories_for_consumers
        )
        self.factories_total = civil_fact
        self.factories = civil_fact - self.factories_from_trade
        self.mil_factories = mil_fact
        self.shipyards = shipyards
        if factories_available > 0:
            self.factories_available = round(factories_available, 0)
        else:
            self.factories_available = 0

Расчет доли получаемых фабрик в классе Region

    def get_compliance_modifier(self):
        # Для национальных пров. self.compliance = None
        if not self.compliance:
            raise Exception(
                "Попытка вычислить контроль национальной территории."
            )
        industry_percent = self.compliance * 0.65 + 25
        if self.compliance > 40:
            industry_percent += 10
        return industry_percent/100    

3. Контроль не национальных территорий

Мы обсудили как контроль влияет на получаемые фабрики, но контроль это величина не постоянная. Впрочем, вычисляется ежедневное изменение контроля по несложной формуле:

изменение_контроля = 0.075 * (1+бонус_контроля) — контроль * 0.00083

Бонусы к росту контроля в целом встречается не часто, но есть один распространенный источник: в мирное время все страны получают +10% к росту контроля.

Расчет ежедневного изменения контроля (метод класса Region)

    def calculate_day(self, compliance_modifier):
        if self.compliance:  # Для национальных пров. self.compliance = None
            grow = (1+compliance_modifier) * 0.075
            decay = self.compliance * 0.00083
            self.compliance += grow - decay
        else:
            raise Exception(
                "Попытка рассчитать рост контроля в национальной провинции."
            )

4. Увеличение количества доступных ячеек зданий

В пункте 1 мы говорили о том что есть ограничение на количество зданий в провинции. Есть два способа добавления ячеек: решение за 100 политической власти и изучение технологии промышленности. Первый способ используется скорее в поздней игре, когда все нужные советники уже выбраны, поэтому мы его проигнорируем. А вот второй способ нам очень важен. К счастью формула нам известна:

новый_лимит = стартовый_лимит*модификатор_технологии

Округляется это дело вниз (как впрочем и всё в hearts of iron 4).

Расчет нового лимита в классе Region

    def _recalculate_available_slots(self):
        self.available_for_construction = (
                self.factories_limit
                - self.factories
                - self.military_factories
                - self.shipyards
                - self.fuel_silo
                - self.synth_oil
        )
        self.available_for_queue = (
                self.available_for_construction
                - self.slot_for_queue
        )
        self.available_for_infrastructure = (
            self.infrastructure
            - self.infrastructure_queue_slots
        )
        self.available_for_infrastructure_queue = (
            self.available_for_infrastructure
            - self.infrastructure_queue_slots
        )

Метод улучшения технологии в классе Country

    def upgrade_industry_tech(self):
        if self.industry_tech >= 5:
            raise Exception("Лимит технологии 5 уровень")
        elif self._distributed_industry:
            self.mil_output_bonus += 0.1
        else:
            self.mil_output_bonus += 0.15
        self.industry_tech += 1
        self.factory_limit_bonus += 0.2
        for region in self.regions:
            region.recalculate_factories_limit(self.factory_limit_bonus)

Завершающие слова о симуляции экономики

В сущности эти 4 вещи и определяют всю экономику hoi4. Оставшиеся вещи это лирика (по большей части лишь работа с модификаторами для указанных 4 пунктов), полагаю не стоит особо тратить на них время. Вместо этого давайте взглянем на пару примеров тестирования получившегося кода.

На начальных этапах тестирования сравнивались результаты прогресса строительства выдаваемые программой с внутриигровыми. Испытываемым государством была Франция освободившая свои колонии (т.к.фабрик там почти нет, а регионы на тот момент вписывались вручную).

Результаты тестирования (с картинками)

Программа выдает 2092 и 5455 прогресса строительства на 1 января 1937 года.

f46c1c24d72de2bd718a882efbde2760.pngb3719b27401800eb4f8727d8da1ee9a8.png

Но разумеется тестировалось это всё не ручным сравнением дата проверялась не одна

class DayAfterDayFrance:
    name = "День за днем Французский"

    def __init__(self):
        self.country = get_france_for_tests_2_and_3()
        self.country.move_trade(+1)
        self.country.move_trade(+1)
        self.factories365 = 32
        self.days = {
            0: (0, 0, 0,),  # старт
            1: (94, 12, 0,),
            2: (189, 25, 0,),
            3: (283, 37, 0,),
            4: (378, 50, 0),
            5: (472, 63, 0),
            6: (567, 75, 0),
            7: (661, 88, 0),  # смотрим 1 неделю
            31: (2929, 390, 0),  # 1 февраля
            59: (5575, 743, 0),  # 1 марта
            90: (8505, 1134, 0),  # 1 апреля
            120: (540, 1512, 0),  # 1 мая
            151: (3469, 1902, 0),  # 1 июня
            181: (6304, 2280, 0),  # 1 июля
            212: (9234, 2671, 0),  # 1 августа
            243: (1363, 3150, 0),  # 1 сентября
            273: (4198, 3717, 0),  # 1 октября
            304: (7128, 4302, 0),  # 1 ноября
            334: (9963, 4869, 0),  # 1 декабря
            337: (10246, 4926, 0),  # 4 декабря
            339: (10435, 4964, 0),  # 6 декабря
            341: (10624, 5002, 0),  # 8 декабря
            343: (13, 5040, 0),  # 10 декабря
            353: (958, 5229, 0),  # 20 декабря
            365: (2092, 5455, 0),  # 1 января
            730: (0, 0, 8643)  # 1 января
        }

    def check(self, text=False):
        region_ids = [8, 10, 5]
        regions = self.country.regions
        no_problems = True
        for day in range(731):
            if day in self.days.keys():
                no_problem_in_the_day = True
                for x in range(3):
                    # floor не настоящий, он сперва округляет до 3 знака после ","
                    if (
                        floor(regions[region_ids[x]].civil_constr_progress)
                        != self.days[day][x] and
                        self.days[day][x] != 0
                    ):
                        no_problems = False
                        no_problem_in_the_day = False
                if not no_problem_in_the_day:
                    for_print = []
                    for i in region_ids:
                        for_print.append(floor(
                            regions[i].civil_constr_progress)
                        )
                    if text:
                        print(
                            f"День {day} не совпадает. "
                            f"Ожидаем/получили [{self.days[day][0]}, "
                            f"{self.days[day][1]}, "
                            f"{self.days[day][2]}]/"
                            f"{for_print}. "
                        )
            self.country.calculate_day(day)
        return no_problems

Аналогично я поступил с контролем, но тут уже усложнять не стал, просто сравнил пару дат для одного региона.

Результаты тестирования (с картинками)

Ожидаем перехода с 77.60% на 77.70% с 1 на 2 января.

f6250c3cbb450391fee0a4ddd328fe87.pngc77762945735dc7a058857a351e09d61.png

Что и получаем.

Код теста

class ComplianceTest:
    name = "Проверка контроля (Франция)"

    def __init__(self):
        data = get_data()
        self.country = get_country(data=data, name_or_tag="FRA", by_tag=True)
        self.data = {
            364: 77.6,
            365: 77.7,
        }
        self.result = {}

    def check(self, text=False):
        successful = True
        for day in range(367):
            self.country.calculate_day(day=day)
            target_region = None
            for region in self.country.non_core_regions:
                if region.global_id == 781:
                    target_region = region
            compliance = floor(target_region.compliance*10)/10
            if day in self.data.keys():
                self.result[day] = compliance
                if self.data[day] != compliance:
                    successful = False
        if (
            successful
        ):
            return True
        else:
            if text:
                print(
                    f"Целевые показатели {self.data}\n"
                    f"Получили - {self.result}"
                )
            return False

Откуда данные

Данные, разумеется, я не вводил вручную (кроме парочки самых ранних тестов). Данные о регионах взяты из файлов игры (/history/states/). Нужно сказать что названия регионов не всегда совпадает с названиями в игре (а в данные локализации мне лезть лень), а первый по порядку регион каждого государства назван также как государство. Данные из текстовых файлов я преобразую в файл .json, который и читается при выполнении программы.

Основной код чтения данных из файлов игры

from os import path, listdir
from ast import literal_eval
from constants_and_settings.constants import PATH_TO_PROVINCES, GAME_DATA_FILE_TYPE


def is_text(letter):
    if letter.isalpha():
        return True
    elif letter == "_":
        return True
    else:
        return False


# noinspection PyTypeChecker
def add_quotes(string, maximum_phrases=20):
    """Добавляем кавычки тексту, чтобы python распознавал его."""
    # Исправляем названия dlc
    string = string.replace(
        "Arms Against Tyranny",
        "Arms_Against_Tyranny",
    )
    text = []
    for x in range(maximum_phrases):
        text.append([None, None])
    # Находим фразы
    for x in range(len(string)-1):
        # Избегание кавычек нужно для того,
        # чтобы оставить название с номером.
        if (
            (not is_text(string[x]) and not string[x].isdigit())
            and is_text(string[x+1])
            and string[x] != '"' and string[x+1] != '"'
        ):
            for y in range(maximum_phrases):
                if not text[y][0] is None:
                    continue
                text[y][0] = x+1
                break
        if (
            is_text(string[x]) and
            (not is_text(string[x+1]) and not string[x+1].isdigit())
            and string[x] != '"' and string[x+1] != '"'
        ):
            for y in range(maximum_phrases):
                if text[y][1]:
                    continue
                text[y][1] = x+1
                break
    # Берем в кавычки полностью найденные фразы.
    for phrase in reversed(text):
        if phrase[0] and phrase[1]:
            string = (
                    string[:phrase[0]] + '"' +
                    string[phrase[0]:phrase[1]] + '"' +
                    string[phrase[1]:]
            )
    return string


def separate_numbers(string, max_spaces=6):
    """Разделяем числа запятыми. А также число строка, тоже.
    Ликвидируем все даты."""
    for x in range(len(string)-2):
        if (
            string[x].isdigit() and
            string[x+1] == " " and
            string[x+2].isalpha()
        ):
            string = string[:x+1] + "," + string[x+2:]
        # Табуляцию тоже убираем
        if (
            string[x].isdigit() and
            string[x+1] == "\t" and
            string[x+2].isdigit()
        ):
            string = string[:x+1] + "," + string[x+2:]
    for y in range(max_spaces):
        for x in range(len(string) - 2 - y):
            if (
                string[x].isdigit() and
                string[x+1:x+y+2] == " "*(y+1) and
                string[x+2+y].isdigit()
            ):
                string = string[:x+1] + "," + " "*y + string[x+2:]
    return string


def create_provinces_file(tags):
    provinces_dict = {}  # Словарь с итоговыми данными
    files_list = listdir(PATH_TO_PROVINCES)  # Список путей к файлам
    provinces_list = []  # Список путей к текстовым файлам
    # Заполняем список путей к текстовым файлам
    for file in files_list:
        if file[-len(GAME_DATA_FILE_TYPE):] == GAME_DATA_FILE_TYPE:
            provinces_list.append(path.join(PATH_TO_PROVINCES, file))
    # Цикл заполнения словаря с итоговыми данными
    for file in provinces_list:
        full_file_name = path.basename(file)  # Имя файла вместе с расширением
        file_name = full_file_name[
            :-len(full_file_name.split(".")[-1])-1  # -1 для удаления точки
                    ]  # Имя файла
        province_number = int(file_name.split("-")[0])  # Номер провинции по порядку
        province_name = file_name[
                        len(file_name.split("-")[0]) + 1:  # +1 для удаления "-"
                        ]  # Название провинции
        province_name = province_name.replace("-", "_")
        province_name = province_name.lower()
        province_name = province_name.strip()  # Удаляем пробелы с краев

        with open(file) as link_to_the_file:  # Читаем данные
            raw_string = link_to_the_file.read()  # Получаем сырую строку данных
        # Уничтожаем все даты
        for year in range(36, 51):
            for month in range(1, 13):
                raw_string = raw_string.replace(
                    f"{year}.{month}.",
                    "",
                )
        # Переменные начинающиеся с чисел это ересь.
        # Судя по всему число в начале это дата, или что-то подобное.
        raw_string = raw_string.replace(
            "843.ETH_state_development_production_speed",
            "Why_variable_starts_with_a_number",
        )
        raw_string = raw_string.replace(
            "908.ETH_state_development_production_speed",
            "Another_one"
        )
        raw_list = raw_string.split("\n")  # Делим текст по строкам
        new_list = []  # Будущий итоговый лист с файлом построчно
        # Удаляем пустые строки
        for element_number in reversed(range(len(raw_list))):
            if not raw_list[element_number]:
                raw_list.pop(element_number)
        raw_list[0] = raw_list[0].split("=")[1]  # Удаляем "state="

        # Преобразуем в python код
        for old_line in raw_list:
            if "#" in old_line:
                line = old_line.split("#")[0]  # Удаляем комментарии
            else:
                line = old_line
            # Заменяем "=" на ":" т.к. преобразовываем в словарь
            new_line = line.replace("=", ":")
            # Запятые в конце строки
            if (
                ":" in new_line and
                new_line[-1] != ":" and
                not ("{" in new_line and "}" not in new_line)
                or "}" in new_line
            ):
                new_line = new_line + ","
            new_line = separate_numbers(new_line)  # Разделяем числа запятыми
            new_line = add_quotes(new_line)  # Добавляем свои кавычки
            new_list.append(new_line)  # Для итогового листа
        # Собираем итоговый текст
        new_text = ""
        for line in new_list:
            new_text = f"{new_text}{line}\n"
        data_dict = literal_eval(new_text)[0]

        # Теперь можно начать заполнять наш словарь
        provinces_dict[province_number] = {}  # Добавляем словарь для данных провинции
        # Для удобства берем ссылки на словари
        prov = provinces_dict[province_number]
        history = data_dict.get("history", {})
        buildings = history.get("buildings", {})
        # Читаем данные
        prov["name"] = province_name.replace(" ", "_")  # Добавляем имя
        prov["owner"] = history["owner"].lower()
        prov["cores"] = []
        for line in new_list:
            for tag in tags:
                if "add_core_of" in line and tag in line:
                    prov["cores"].append(tag.lower())
        prov["infrastructure"] = buildings.get("infrastructure", 0)
        prov["factories"] = buildings.get("industrial_complex", 0)
        prov["military_factories"] = buildings.get("arms_factory", 0)
        prov["shipyards"] = buildings.get("dockyard", 0)
        prov["fuel_silo"] = buildings.get("fuel_silo", 0)
        prov["anti_air"] = buildings.get("anti_air_building", 0)
        prov["air_base"] = buildings.get("air_base", 0)
        prov["radar"] = buildings.get("radar_station", 0)
        prov["synth_oil"] = buildings.get("synthetic_refinery", 0)
        # Список типов регионов с максимумами слотов
        lands = {
            "wasteland": 0,
            "enclave": 0,
            "tiny_island": 0,
            "pastoral": 1,
            "small_island": 1,
            "rural": 2,
            "town": 4,
            "large_town": 5,
            "city": 6,
            "large_city": 8,
            "metropolis": 10,
            "megalopolis": 12,
        }
        for k, v in lands.items():  # Устанавливаем максимум слотов
            if data_dict["state_category"] == k:
                prov["max_factories"] = v
    return provinces_dict

Предварительный поиск всех тегов и последующее преобразование в json

from os import path, listdir
from constants_and_settings.constants import PATH_TO_PROVINCES, GAME_DATA_FILE_TYPE


def create_tags_file():
    files_list = listdir(PATH_TO_PROVINCES)  # Список путей к файлам
    provinces_list = []  # Список путей к текстовым файлам
    # Заполняем список путей к текстовым файлам
    for file in files_list:
        if file[-len(GAME_DATA_FILE_TYPE):] == GAME_DATA_FILE_TYPE:
            provinces_list.append(path.join(PATH_TO_PROVINCES, file))
    # Лист с уникальными кодами стран
    tags = []
    # Цикл заполнения словаря с итоговыми данными
    for file in provinces_list:
        with open(file) as link_to_the_file:  # Читаем данные
            raw_string = link_to_the_file.read()  # Получаем сырую строку данных
        raw_list = raw_string.split("\n")  # Делим текст по строкам
        # Удаляем пустые строки
        for element_number in reversed(range(len(raw_list))):
            if not raw_list[element_number]:
                raw_list.pop(element_number)
        for line in raw_list:
            if "add_core_of" in line:
                core = line.split("=")[1]
                if "#" in core:
                    core = core.split("#")[0]
                core = core.strip()
                if core not in tags:
                    tags.append(core)
    return tags
from read_game_data.create_tags_file import create_tags_file
from read_game_data.create_provinces_file import create_provinces_file
from json import dump


tags = create_tags_file()
with open("tags.txt", "w") as json_file:
    dump(tags, json_file)
provinces_dict = create_provinces_file(tags)
with open("provinces.txt", "w") as json_file:
    dump(provinces_dict, json_file)

Заключение

Теперь когда мы обладаем инструментом проверки наших теорий, можно разворачивать оптимизацию. Хотя отдельно можно отметить что быстродействие программы не впечатляет (не то чтобы я вообще пытался над ним работать, так что ожидаемо…). Расчет 2 лет симуляции может занимать до 100 мс, что не так много при одном расчете, но может оказаться преградой при переборе возможных вариантов. Но в конце концов, оптимизация оптимизации ничуть не противоречит тематике статьи, так что не стоит унывать!

Благодарности и обращение к читателям

Выражаю особую благодарность пользователю pokewars (кем бы он ни был) из официального дискорд сервера hoi4. Его ответы в канале modding очень помогли мне разобраться в игровых механиках, сам я тот еще знаток hoi.

Так как на хабре знают значение слова критика (достаточно удивительно для соц сети) можете смело критиковать как саму статью так и код. В общем-то не обладаю богатым опытом ни в той, ни в другой области, так что это может существенно улучшить качество следующей статьи (а может и в этой тоже что-то поправлю).

Ссылки

Ссылка на репозиторий.

Ссылка на сервер в дискорде (на всякий случай).

© Habrahabr.ru