Я научу вас неправильно играть в 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 года.

Но разумеется тестировалось это всё не ручным сравнением дата проверялась не одна
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 января.

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

Код теста
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.

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

Ссылки

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

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


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


Комментарии

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

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