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_attribute_check,
are_within_one_move,
write_faction,
)
from wwn_assets_data import (
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 hostile_boi_in_loc:
return []
return [] # <--- скрытие запрещено
lines = []
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)
if not available:
return [" - *(нет доступных активов для покупки)*"]
return [] # <--- теперь возвращаем пустой список, а не заглушку
lines = []
for a in available:
@ -478,19 +479,42 @@ def _format_faction_section(
create_opts = _build_create_asset_options(fm, location_wikilink)
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:
if not lines:
return f"\n**[{title}]** *(недоступно)*"
return "" # <-- теперь не выводится заголовок "недоступно"
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 (
f"### {idx}. {name} | Initiative: {initiative} | {state_label.upper()}\n"
f"**Статы:** {status_str}"
@ -500,16 +524,87 @@ def _format_faction_section(
f"\n**Доход этого хода:** {income_str}{upkeep_str}"
f"\n\n**Активы в локации:**\n{assets_str}"
f"\n\n#### Доступные действия:"
+ _section("ATTACK", attack_opts)
+ _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)
f"{actions_sections}"
)
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["_upkeep"] = upkeep
# Добавляем бесплатный стартовый актив, если надо
_ensure_startup_asset(faction, factions_folder, location_wikilink)
# ── Сборка вывода ────────────────────────────────────────────────
header = (
f"# Ход фракций: {location_wikilink} | {game_date}\n\n"

View File

@ -346,7 +346,7 @@ def _make_event_content(
def _resolve_attack(
action: dict,
faction_fm: dict,
all_factions_data: dict, # faction_file → (fm, body, path)
all_factions_data: dict,
factions_folder: str,
) -> dict:
"""
@ -355,8 +355,6 @@ def _resolve_attack(
Бросает 1d10+attr vs 1d10+attr с учётом faction tags.
Применяет урон к target_asset или BoI.
При ударе по BoI также наносит урон HP фракции.
Возвращает словарь с результатами для отчёта.
"""
attacking_asset_name = action.get("attacking_asset", "")
target_faction_file = action.get("target_faction", "")
@ -377,6 +375,21 @@ def _resolve_attack(
result["error"] = f"Актив '{attacking_asset_name}' не найден в таблицах WWN"
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_vs = own_asset_def.get("attack_vs", "Force")
atk_dmg_notation = own_asset_def.get("attack_damage", "1d6")
@ -594,7 +607,12 @@ def _resolve_create_asset(
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.
@ -602,6 +620,7 @@ def _resolve_hide_asset(action: dict, faction_fm: dict) -> dict:
нельзя скрывать в локации с вражеским BoI.
"""
asset_name = action.get("asset_name", "")
location = action.get("location", "")
result = {"action": "Hide Asset", "asset": asset_name}
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}' не найден"
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
faction_fm["treasure"] = faction_fm.get("treasure", 0) - 2
result["treasure_spent"] = 2
@ -654,24 +690,32 @@ def _resolve_move_asset(action: dict, faction_fm: dict) -> dict:
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 = "faction" ремонтирует HP самой фракции.
repair_asset = имя актива ремонтирует актив.
Параметр repair_application_number номер применения ремонта к этому объекту в текущем ходу
(1 первое применение, 2 второе и т.д.).
Стоимость = номер применения (1 Treasure за первое, 2 за второе, ...).
"""
repair_target = action.get("repair_asset", "")
result = {"action": "Repair Asset", "target": repair_target}
treasure = faction_fm.get("treasure", 0)
cost = repair_application_number # стоимость равна номеру применения
if repair_target == "faction":
if treasure < 1:
result["error"] = "Недостаточно Treasure для ремонта фракции"
if treasure < cost:
result["error"] = f"Недостаточно Treasure (нужно {cost}, есть {treasure})"
return result
repair_info = calc_faction_repair_heal(faction_fm)
cost = repair_info["cost"]
heal = repair_info["heal"]
old_hp = faction_fm.get("hp", 0)
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}' не найден"
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:
result["error"] = f"Недостаточно Treasure (нужно {cost}, есть {treasure})"
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)
hp_max = asset.get("hp_max", 1)
asset["hp"] = min(hp_max, old_hp + heal)
@ -716,7 +760,7 @@ def _resolve_expand_influence(
Выполняет действие Expand Influence по правилам WWN.
Создаёт новый BoI в expand_location за expand_hp Treasure.
Затем выполняет Cunning vs Cunning против каждой фракции с BoI в локации.
Затем выполняет Cunning vs Cunning против каждой фракции с любым активом в локации.
При проигрыше враг получает право на немедленную атаку (помечается в отчёте).
"""
expand_loc = action.get("expand_location", action.get("location", ""))
@ -727,32 +771,28 @@ def _resolve_expand_influence(
"boi_hp": desired_hp,
}
# Получаем текущий список (если его нет, создаем пустой)
if "locations" not in faction_fm:
faction_fm["locations"] = []
# Проверяем, нет ли уже этой локации в списке (нормализованно)
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
treasure = faction_fm.get("treasure", 0)
if treasure < cost:
# Если денег не хватило, удаляем локацию обратно (откат)
if expand_loc in faction_fm["locations"]:
faction_fm["locations"].remove(expand_loc)
result["error"] = f"Недостаточно Treasure (нужно {cost}, есть {treasure})"
return result
# Создаём BoI
loc_norm = normalize_wikilink(expand_loc)
# ----- Создаём BoI -----
boi_name = f"Base of Influence — {expand_loc.strip('[]')}"
new_boi = {
"name": boi_name,
@ -770,17 +810,28 @@ def _resolve_expand_influence(
result["treasure_after"] = faction_fm["treasure"]
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)
contesting_checks = []
for tf_file, (tf_fm, _tf_body, _tf_path) in all_factions_data.items():
# Проверяем есть ли у них BoI в expand_loc
tf_has_boi = any(
a.get("is_base_of_influence") and normalize_wikilink(a.get("location", "")) == loc_norm
# Пропускаем себя
if tf_file == action.get("faction_file", ""):
continue
# Проверяем, есть ли у этой фракции хотя бы один актив (любой) в локации
tf_has_asset = any(
normalize_wikilink(a.get("location", "")) == loc_norm
for a in tf_fm.get("assets", [])
)
if not tf_has_boi:
if not tf_has_asset:
continue
tf_cunning = tf_fm.get("cunning", 0)
@ -952,6 +1003,9 @@ def resolve_faction_turn(
action_results: list[dict] = []
event_files_created: list[str] = []
# ---- В начале цикла обработки действий инициализируем счётчики ремонта ----
repair_counters = {} # ключ: (faction_file, target_name) -> количество применений в этом ходу
for action in actions:
faction_file = action.get("faction_file", "")
action_type = action.get("action_type", "")
@ -968,24 +1022,29 @@ def resolve_faction_turn(
fm, body, path = all_factions_data[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))
elif action_type == "Create Asset":
result.update(_resolve_create_asset(action, fm, location))
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":
result.update(_resolve_move_asset(action, fm))
elif action_type == "Repair Asset":
result.update(_resolve_repair_asset(action, fm))
elif action_type == "Expand Influence":
result.update(_resolve_expand_influence(action, fm, all_factions_data))
elif action_type == "Sell Asset":
result.update(_resolve_sell_asset(action, fm))