Многие люди просто играют в игру, почти не уделяя времени оптимизации своей стратегии, мы не будем повторять их ошибок! Прежде чем начинать играть, мы приложим все усилия чтобы найти наиболее оптимальную стратегию развития виртуального государства. Основой всего в этом вопросе является, конечно, экономика, на неё мы и обратим внимание в первую очередь.
Теперь, когда мы определились с объектом оптимизации, нам важно осознать, что оптимизация это дело серьезное и ответственное, мы никак не можем полагаться на чей либо опыт или мнение. Нам необходимо провести собственные исследования! Краеугольным камнем в этом вопросе является проверяемость результата оптимизации. Кто-то мог бы подумать, что проверить результаты можно в игре, но этот процесс занимает уйму времени (да и не для того мы с вами здесь собрались чтобы играть в игру). Нет, наш путь ясен как день, это путь обратной разработки!
Формулы по которым работает внутриигровая экономика известны и относительно хорошо задокументированы, что весьма удачно (хотя чтобы протестировать нашу симуляцию все таки придется собрать некоторые данные в игре). Разумеется, на момент написания статьи код уже готов. Что же, приступим к разбору игровых механик и их реализации в 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.
Так как на хабре знают значение слова критика (достаточно удивительно для соц сети), можете смело критиковать как саму статью, так и код. В общем-то не обладаю богатым опытом ни в той, ни в другой области, так что это может существенно улучшить качество следующей статьи (а может и в этой тоже что-то поправлю).
