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