Fix propose/resolve faction turn

This commit is contained in:
dimitrievgs 2026-07-06 22:25:15 +03:00
parent 1ec3b18bb1
commit 74d580e740
2 changed files with 218 additions and 61 deletions

View File

@ -40,6 +40,7 @@ from faction_utils import (
roll_dice, roll_dice,
roll_attribute_check, roll_attribute_check,
are_within_one_move, are_within_one_move,
write_faction,
) )
from wwn_assets_data import ( from wwn_assets_data import (
get_available_assets, get_available_assets,
@ -279,7 +280,7 @@ def _build_hide_asset_options(faction_fm: dict, all_factions: list[dict], locati
if tf.get("name") != faction_fm.get("name") if tf.get("name") != faction_fm.get("name")
) )
if hostile_boi_in_loc: if hostile_boi_in_loc:
return [] return [] # <--- скрытие запрещено
lines = [] lines = []
for a in faction_fm.get("assets", []): for a in faction_fm.get("assets", []):
@ -363,7 +364,7 @@ def _build_create_asset_options(faction_fm: dict, location_wikilink: str) -> lis
""" """
available = get_available_assets(faction_fm, location_wikilink) available = get_available_assets(faction_fm, location_wikilink)
if not available: if not available:
return [" - *(нет доступных активов для покупки)*"] return [] # <--- теперь возвращаем пустой список, а не заглушку
lines = [] lines = []
for a in available: for a in available:
@ -478,19 +479,42 @@ def _format_faction_section(
create_opts = _build_create_asset_options(fm, location_wikilink) create_opts = _build_create_asset_options(fm, location_wikilink)
sell_opts = _build_sell_options(fm) sell_opts = _build_sell_options(fm)
# Expand Influence # ----- ИСПРАВЛЕННАЯ ВСПОМОГАТЕЛЬНАЯ ФУНКЦИЯ -----
treasure = fm.get("treasure", 0) # Возвращает пустую строку, если список опций пуст
expand_str = (
f" - Создать BoI в новой локации: 1 Treasure/HP нового BoI "
f"(текущий Treasure: {treasure}, максимальный BoI HP: {treasure})\n"
f" Потребует Cunning vs Cunning vs всех фракций с BoI в целевой локации"
)
def _section(title: str, lines: list[str]) -> str: def _section(title: str, lines: list[str]) -> str:
if not lines: if not lines:
return f"\n**[{title}]** *(недоступно)*" return "" # <-- теперь не выводится заголовок "недоступно"
return f"\n**[{title}]**\n" + "\n".join(lines) return f"\n**[{title}]**\n" + "\n".join(lines)
# ----- Блок Expand Influence (теперь только если доступен) -----
treasure = fm.get("treasure", 0)
has_regular_asset_in_loc = any(
not a.get("is_base_of_influence") and normalize_wikilink(a.get("location", "")) == loc_norm
for a in fm.get("assets", [])
)
expand_section = ""
if has_regular_asset_in_loc:
expand_str = (
f" - Создать BoI в новой локации: 1 Treasure/HP нового BoI "
f"(текущий Treasure: {treasure}, максимальный BoI HP: {treasure})\n"
f" Потребует Cunning vs Cunning vs всех фракций с любым активом в целевой локации"
)
expand_section = f"\n**[EXPAND INFLUENCE]**\n{expand_str}"
# Собираем все секции, кроме EXPAND INFLUENCE (он уже отдельно)
actions_sections = (
_section("ATTACK", attack_opts) +
_section("CREATE ASSET", create_opts) +
_section("HIDE ASSET", hide_opts) +
_section("MOVE ASSET", move_opts) +
_section("REPAIR ASSET", repair_opts) +
expand_section +
_section("SELL ASSET", sell_opts)
)
if not actions_sections.strip():
actions_sections = "\n*(нет доступных действий — фракция пропускает ход)*"
return ( return (
f"### {idx}. {name} | Initiative: {initiative} | {state_label.upper()}\n" f"### {idx}. {name} | Initiative: {initiative} | {state_label.upper()}\n"
f"**Статы:** {status_str}" f"**Статы:** {status_str}"
@ -500,16 +524,87 @@ def _format_faction_section(
f"\n**Доход этого хода:** {income_str}{upkeep_str}" f"\n**Доход этого хода:** {income_str}{upkeep_str}"
f"\n\n**Активы в локации:**\n{assets_str}" f"\n\n**Активы в локации:**\n{assets_str}"
f"\n\n#### Доступные действия:" f"\n\n#### Доступные действия:"
+ _section("ATTACK", attack_opts) f"{actions_sections}"
+ _section("CREATE ASSET", create_opts)
+ _section("HIDE ASSET", hide_opts)
+ _section("MOVE ASSET", move_opts)
+ _section("REPAIR ASSET", repair_opts)
+ f"\n**[EXPAND INFLUENCE]**\n{expand_str}"
+ _section("SELL ASSET", sell_opts)
) )
def _ensure_startup_asset(
faction: dict,
factions_folder: str,
default_location: str = "[[Уотердип]]"
) -> bool:
"""
Проверяет, есть ли у фракции активы (включая BoI). Если нет, и фракция новая
(нет last_turn_date и last_action), то добавляет самый дешёвый актив tier 1
в первую локацию из списка locations (или default_location) БЕСПЛАТНО.
"""
fm = faction.get("frontmatter", {})
# Пропускаем побеждённые или мёртвые
if fm.get("state") == "defeated" or fm.get("hp", 0) <= 0:
return False
# Если есть хоть один актив (включая BoI) — ничего не делаем
if fm.get("assets"):
return False
# Если фракция уже действовала ранее — не даём халявный актив
if fm.get("last_turn_date") or fm.get("last_action"):
return False
# Выбираем локацию
locations = fm.get("locations", [])
if not locations:
loc = default_location
fm.setdefault("locations", []).append(default_location)
else:
loc = locations[0]
# Определяем наивысший атрибут (приоритет: Cunning > Force > Wealth)
attrs = {
"Cunning": fm.get("cunning", 0),
"Force": fm.get("force", 0),
"Wealth": fm.get("wealth", 0),
}
priority = {"Cunning": 0, "Force": 1, "Wealth": 2}
sorted_attrs = sorted(attrs.items(), key=lambda x: (-x[1], priority[x[0]]))
best_attr = sorted_attrs[0][0]
from wwn_assets_data import get_assets_by_attribute, ALL_ASSETS
candidates = [a for a in get_assets_by_attribute(best_attr) if a["tier_required"] == 1]
if not candidates:
candidates = [a for a in ALL_ASSETS if a["tier_required"] == 1]
if not candidates:
return False
best_asset = min(candidates, key=lambda a: a["cost"])
# Создаём актив бесплатно (не тратим Treasure)
new_asset = {
"name": best_asset["name"],
"is_base_of_influence": False,
"attribute": best_asset["attribute"],
"tier_required": best_asset["tier_required"],
"cost": best_asset["cost"],
"hp": best_asset["hp_max"],
"hp_max": best_asset["hp_max"],
"location": loc,
"stealthed": False, # по умолчанию активы не скрыты, даже если имеют Subtle
"subtle": False,
}
fm.setdefault("assets", []).append(new_asset)
# Treasure не изменяется
# Сохраняем изменения в файл
file_path = faction.get("path")
if file_path:
body = faction.get("body", "")
success = write_faction(file_path, fm, body)
if success:
faction["frontmatter"] = fm
return True
return False
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Главная функция # Главная функция
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -602,6 +697,9 @@ def propose_faction_turn(
faction["_income"] = income faction["_income"] = income
faction["_upkeep"] = upkeep faction["_upkeep"] = upkeep
# Добавляем бесплатный стартовый актив, если надо
_ensure_startup_asset(faction, factions_folder, location_wikilink)
# ── Сборка вывода ──────────────────────────────────────────────── # ── Сборка вывода ────────────────────────────────────────────────
header = ( header = (
f"# Ход фракций: {location_wikilink} | {game_date}\n\n" f"# Ход фракций: {location_wikilink} | {game_date}\n\n"

View File

@ -346,7 +346,7 @@ def _make_event_content(
def _resolve_attack( def _resolve_attack(
action: dict, action: dict,
faction_fm: dict, faction_fm: dict,
all_factions_data: dict, # faction_file → (fm, body, path) all_factions_data: dict,
factions_folder: str, factions_folder: str,
) -> dict: ) -> dict:
""" """
@ -355,8 +355,6 @@ def _resolve_attack(
Бросает 1d10+attr vs 1d10+attr с учётом faction tags. Бросает 1d10+attr vs 1d10+attr с учётом faction tags.
Применяет урон к target_asset или BoI. Применяет урон к target_asset или BoI.
При ударе по BoI также наносит урон HP фракции. При ударе по BoI также наносит урон HP фракции.
Возвращает словарь с результатами для отчёта.
""" """
attacking_asset_name = action.get("attacking_asset", "") attacking_asset_name = action.get("attacking_asset", "")
target_faction_file = action.get("target_faction", "") target_faction_file = action.get("target_faction", "")
@ -377,6 +375,21 @@ def _resolve_attack(
result["error"] = f"Актив '{attacking_asset_name}' не найден в таблицах WWN" result["error"] = f"Актив '{attacking_asset_name}' не найден в таблицах WWN"
return result return result
# ----- Проверка: атакующий актив должен находиться в той же локации, что и цель -----
own_asset_ref = _find_asset_in_faction(faction_fm, attacking_asset_name)
if not own_asset_ref:
result["error"] = f"Актив '{attacking_asset_name}' не найден у атакующей фракции"
return result
own_loc = normalize_wikilink(own_asset_ref.get("location", ""))
target_loc = normalize_wikilink(location) # цель должна быть в этой локации
if own_loc != target_loc:
result["error"] = (
f"Атакующий актив находится в {own_loc}, а цель указана в {target_loc}. "
"Атака возможна только в одной локации."
)
return result
atk_attr = own_asset_def.get("attack_attribute", "Force") atk_attr = own_asset_def.get("attack_attribute", "Force")
atk_vs = own_asset_def.get("attack_vs", "Force") atk_vs = own_asset_def.get("attack_vs", "Force")
atk_dmg_notation = own_asset_def.get("attack_damage", "1d6") atk_dmg_notation = own_asset_def.get("attack_damage", "1d6")
@ -594,7 +607,12 @@ def _resolve_create_asset(
return result return result
def _resolve_hide_asset(action: dict, faction_fm: dict) -> dict: def _resolve_hide_asset(
action: dict,
faction_fm: dict,
all_factions_data: dict,
own_faction_file: str,
) -> dict:
""" """
Выполняет действие Hide Asset по правилам WWN. Выполняет действие Hide Asset по правилам WWN.
@ -602,6 +620,7 @@ def _resolve_hide_asset(action: dict, faction_fm: dict) -> dict:
нельзя скрывать в локации с вражеским BoI. нельзя скрывать в локации с вражеским BoI.
""" """
asset_name = action.get("asset_name", "") asset_name = action.get("asset_name", "")
location = action.get("location", "")
result = {"action": "Hide Asset", "asset": asset_name} result = {"action": "Hide Asset", "asset": asset_name}
if faction_fm.get("cunning", 0) < 3: if faction_fm.get("cunning", 0) < 3:
@ -617,6 +636,23 @@ def _resolve_hide_asset(action: dict, faction_fm: dict) -> dict:
result["error"] = f"Актив '{asset_name}' не найден" result["error"] = f"Актив '{asset_name}' не найден"
return result return result
# ----- Проверка на вражеский BoI в той же локации -----
loc_norm = normalize_wikilink(location)
hostile_boi = any(
normalize_wikilink(a.get("location", "")) == loc_norm
and a.get("is_base_of_influence")
for tf_file, (tf_fm, _, _) in all_factions_data.items()
if tf_file != own_faction_file
for a in tf_fm.get("assets", [])
)
if hostile_boi:
result["error"] = (
f"В локации {location} присутствует вражеский BoI — скрытие невозможно "
"(правило стр. 326)."
)
return result
# Применяем скрытие
asset["stealthed"] = True asset["stealthed"] = True
faction_fm["treasure"] = faction_fm.get("treasure", 0) - 2 faction_fm["treasure"] = faction_fm.get("treasure", 0) - 2
result["treasure_spent"] = 2 result["treasure_spent"] = 2
@ -654,24 +690,32 @@ def _resolve_move_asset(action: dict, faction_fm: dict) -> dict:
return result return result
def _resolve_repair_asset(action: dict, faction_fm: dict) -> dict: def _resolve_repair_asset(
action: dict,
faction_fm: dict,
repair_application_number: int, # номер применения ремонта для данного целевого объекта (актива или faction)
) -> dict:
""" """
Выполняет действие Repair Asset по правилам WWN. Выполняет действие Repair Asset по правилам WWN.
repair_asset = "faction" ремонтирует HP самой фракции. repair_asset = "faction" ремонтирует HP самой фракции.
repair_asset = имя актива ремонтирует актив. repair_asset = имя актива ремонтирует актив.
Параметр repair_application_number номер применения ремонта к этому объекту в текущем ходу
(1 первое применение, 2 второе и т.д.).
Стоимость = номер применения (1 Treasure за первое, 2 за второе, ...).
""" """
repair_target = action.get("repair_asset", "") repair_target = action.get("repair_asset", "")
result = {"action": "Repair Asset", "target": repair_target} result = {"action": "Repair Asset", "target": repair_target}
treasure = faction_fm.get("treasure", 0) treasure = faction_fm.get("treasure", 0)
cost = repair_application_number # стоимость равна номеру применения
if repair_target == "faction": if repair_target == "faction":
if treasure < 1: if treasure < cost:
result["error"] = "Недостаточно Treasure для ремонта фракции" result["error"] = f"Недостаточно Treasure (нужно {cost}, есть {treasure})"
return result return result
repair_info = calc_faction_repair_heal(faction_fm) repair_info = calc_faction_repair_heal(faction_fm)
cost = repair_info["cost"]
heal = repair_info["heal"] heal = repair_info["heal"]
old_hp = faction_fm.get("hp", 0) old_hp = faction_fm.get("hp", 0)
hp_max = faction_fm.get("hp_max", 1) hp_max = faction_fm.get("hp_max", 1)
@ -686,15 +730,15 @@ def _resolve_repair_asset(action: dict, faction_fm: dict) -> dict:
result["error"] = f"Актив '{repair_target}' не найден" result["error"] = f"Актив '{repair_target}' не найден"
return result return result
attr = asset.get("attribute", "Force")
repair_info = calc_repair_heal(faction_fm, attr, 1)
cost = repair_info["cost"]
heal = repair_info["heal"]
if treasure < cost: if treasure < cost:
result["error"] = f"Недостаточно Treasure (нужно {cost}, есть {treasure})" result["error"] = f"Недостаточно Treasure (нужно {cost}, есть {treasure})"
return result return result
attr = asset.get("attribute", "Force")
# heal вычисляется по формуле из WWN: ceil(attr_value / 2)
attr_value = faction_fm.get(attr.lower(), 0)
heal = math.ceil(attr_value / 2)
old_hp = asset.get("hp", 0) old_hp = asset.get("hp", 0)
hp_max = asset.get("hp_max", 1) hp_max = asset.get("hp_max", 1)
asset["hp"] = min(hp_max, old_hp + heal) asset["hp"] = min(hp_max, old_hp + heal)
@ -716,7 +760,7 @@ def _resolve_expand_influence(
Выполняет действие Expand Influence по правилам WWN. Выполняет действие Expand Influence по правилам WWN.
Создаёт новый BoI в expand_location за expand_hp Treasure. Создаёт новый BoI в expand_location за expand_hp Treasure.
Затем выполняет Cunning vs Cunning против каждой фракции с BoI в локации. Затем выполняет Cunning vs Cunning против каждой фракции с любым активом в локации.
При проигрыше враг получает право на немедленную атаку (помечается в отчёте). При проигрыше враг получает право на немедленную атаку (помечается в отчёте).
""" """
expand_loc = action.get("expand_location", action.get("location", "")) expand_loc = action.get("expand_location", action.get("location", ""))
@ -727,32 +771,28 @@ def _resolve_expand_influence(
"boi_hp": desired_hp, "boi_hp": desired_hp,
} }
# Получаем текущий список (если его нет, создаем пустой)
if "locations" not in faction_fm:
faction_fm["locations"] = []
# Проверяем, нет ли уже этой локации в списке (нормализованно)
loc_norm = normalize_wikilink(expand_loc) loc_norm = normalize_wikilink(expand_loc)
existing_locs_norm = [normalize_wikilink(l) for l in faction_fm["locations"]]
if loc_norm not in existing_locs_norm:
# Добавляем именно в том виде, в котором пришло (например, "[[Neverwinter]]")
faction_fm["locations"].append(expand_loc)
# ----- Проверка: у создающей фракции должен быть хотя бы один актив в локации (не BoI) -----
has_asset = any(
not a.get("is_base_of_influence") and normalize_wikilink(a.get("location", "")) == loc_norm
for a in faction_fm.get("assets", [])
)
if not has_asset:
result["error"] = (
f"Нет активов (кроме BoI) в локации {expand_loc} для создания нового BoI. "
"Требуется хотя бы один обычный актив."
)
return result
# ----- Проверка Treasure -----
cost = desired_hp # 1 Treasure per HP cost = desired_hp # 1 Treasure per HP
treasure = faction_fm.get("treasure", 0) treasure = faction_fm.get("treasure", 0)
if treasure < cost: if treasure < cost:
# Если денег не хватило, удаляем локацию обратно (откат)
if expand_loc in faction_fm["locations"]:
faction_fm["locations"].remove(expand_loc)
result["error"] = f"Недостаточно Treasure (нужно {cost}, есть {treasure})" result["error"] = f"Недостаточно Treasure (нужно {cost}, есть {treasure})"
return result return result
# Создаём BoI # ----- Создаём BoI -----
loc_norm = normalize_wikilink(expand_loc)
boi_name = f"Base of Influence — {expand_loc.strip('[]')}" boi_name = f"Base of Influence — {expand_loc.strip('[]')}"
new_boi = { new_boi = {
"name": boi_name, "name": boi_name,
@ -770,17 +810,28 @@ def _resolve_expand_influence(
result["treasure_after"] = faction_fm["treasure"] result["treasure_after"] = faction_fm["treasure"]
result["boi_created"] = boi_name result["boi_created"] = boi_name
# Cunning checks против враждебных фракций в локации # ----- Добавляем локацию в список regions (если ещё нет) -----
if "locations" not in faction_fm:
faction_fm["locations"] = []
existing_locs_norm = [normalize_wikilink(l) for l in faction_fm["locations"]]
if loc_norm not in existing_locs_norm:
faction_fm["locations"].append(expand_loc)
# ----- Cunning checks против ВСЕХ фракций, у которых есть ЛЮБОЙ актив в локации -----
own_cunning = faction_fm.get("cunning", 0) own_cunning = faction_fm.get("cunning", 0)
contesting_checks = [] contesting_checks = []
for tf_file, (tf_fm, _tf_body, _tf_path) in all_factions_data.items(): for tf_file, (tf_fm, _tf_body, _tf_path) in all_factions_data.items():
# Проверяем есть ли у них BoI в expand_loc # Пропускаем себя
tf_has_boi = any( if tf_file == action.get("faction_file", ""):
a.get("is_base_of_influence") and normalize_wikilink(a.get("location", "")) == loc_norm continue
# Проверяем, есть ли у этой фракции хотя бы один актив (любой) в локации
tf_has_asset = any(
normalize_wikilink(a.get("location", "")) == loc_norm
for a in tf_fm.get("assets", []) for a in tf_fm.get("assets", [])
) )
if not tf_has_boi: if not tf_has_asset:
continue continue
tf_cunning = tf_fm.get("cunning", 0) tf_cunning = tf_fm.get("cunning", 0)
@ -952,6 +1003,9 @@ def resolve_faction_turn(
action_results: list[dict] = [] action_results: list[dict] = []
event_files_created: list[str] = [] event_files_created: list[str] = []
# ---- В начале цикла обработки действий инициализируем счётчики ремонта ----
repair_counters = {} # ключ: (faction_file, target_name) -> количество применений в этом ходу
for action in actions: for action in actions:
faction_file = action.get("faction_file", "") faction_file = action.get("faction_file", "")
action_type = action.get("action_type", "") action_type = action.get("action_type", "")
@ -968,24 +1022,29 @@ def resolve_faction_turn(
fm, body, path = all_factions_data[faction_file] fm, body, path = all_factions_data[faction_file]
result = {"faction": faction_file} result = {"faction": faction_file}
if action_type == "Attack": # ----- Для Repair Asset обновляем счётчик -----
if action_type == "Repair Asset":
repair_target = action.get("repair_asset", "")
key = (faction_file, repair_target)
repair_counters[key] = repair_counters.get(key, 0) + 1
repair_application_number = repair_counters[key]
result.update(_resolve_repair_asset(action, fm, repair_application_number))
# ----- Остальные действия без изменений (кроме Hide Asset и Attack, которые теперь требуют дополнительные параметры) -----
elif action_type == "Attack":
result.update(_resolve_attack(action, fm, all_factions_data, factions_folder)) result.update(_resolve_attack(action, fm, all_factions_data, factions_folder))
elif action_type == "Create Asset": elif action_type == "Create Asset":
result.update(_resolve_create_asset(action, fm, location)) result.update(_resolve_create_asset(action, fm, location))
elif action_type == "Hide Asset": elif action_type == "Hide Asset":
result.update(_resolve_hide_asset(action, fm)) result.update(_resolve_hide_asset(action, fm, all_factions_data, faction_file))
elif action_type == "Move Asset": elif action_type == "Move Asset":
result.update(_resolve_move_asset(action, fm)) result.update(_resolve_move_asset(action, fm))
elif action_type == "Repair Asset":
result.update(_resolve_repair_asset(action, fm))
elif action_type == "Expand Influence": elif action_type == "Expand Influence":
result.update(_resolve_expand_influence(action, fm, all_factions_data)) result.update(_resolve_expand_influence(action, fm, all_factions_data))
elif action_type == "Sell Asset": elif action_type == "Sell Asset":
result.update(_resolve_sell_asset(action, fm)) result.update(_resolve_sell_asset(action, fm))