dnd5-scripts/faction_turns/resolve_faction_turn.py

1446 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
resolve_faction_turn.py — Тул 2 системы фракций WWN.
Применяет выбранные ГМом действия фракций: бросает кубики, наносит урон,
обновляет YAML-файлы фракций в Obsidian-хранилище, создаёт файлы событий.
Экспортирует функцию resolve_faction_turn(debug_description, requests),
которую вызывает тул с фронта.
requests — список объектов вида:
{
"game_date": "15 Mirtul 1492", # игровая дата
"factions_folder": "Фракции", # папка фракций
"events_folder": "События", # папка событий
"actions": [ # список действий фракций
{
"faction_file": "Cult_of_the_Dragon", # имя файла без .md
"action_type": "Attack",
"attacking_asset": "Organization Moles",
"target_faction": "Harpers",
"target_asset": "Informers",
"location": "[[Waterdeep]]",
},
{
"faction_file": "Harpers",
"action_type": "Create Asset",
"asset_name": "Spymaster",
"location": "[[Waterdeep]]",
},
...
]
}
Возвращает строку в формате Markdown — сухой отчёт об изменениях для LLM.
"""
import random
import math
import re
from datetime import date
from typing import TypedDict, Optional
try:
from typing_extensions import TypedDict
except ImportError:
from typing import TypedDict
from faction_utils import (
get_file,
get_all_factions,
write_faction,
create_event_file,
normalize_wikilink,
normalize_faction_frontmatter,
roll_dice,
roll_attribute_check,
resolve_wikilink,
)
from wwn_assets_data import (
get_asset,
MAGIC_LEVELS,
)
from wwn_mechanics import (
calc_income,
calc_asset_upkeep,
calc_repair_heal,
calc_faction_repair_heal,
get_attack_extra_die_flags,
has_antimagical_penalty,
check_massive_auto_win,
calc_xp_cost_to_raise,
get_available_upgrades,
calc_hp_max,
)
# ---------------------------------------------------------------------------
# TypedDict
# ---------------------------------------------------------------------------
from generate_npc.regions import match_location
class FactionAction(TypedDict, total=False):
"""
Параметры одного действия одной фракции за ход.
Поля:
faction_file: Имя файла фракции без расширения .md.
Обязательное поле.
Пример: "Cult_of_the_Dragon", "Harpers".
action_type: Тип действия по WWN.
Допустимы: "Attack", "Move Asset", "Repair Asset",
"Expand Influence", "Create Asset", "Hide Asset", "Sell Asset".
Обязательное поле.
world: Космологический уровень сцены (англ.).
По умолчанию: "Toril".
location: Имя локации на английском языке.
Обязательное поле.
Пример: "Waterdeep", "Neverwinter".
attacking_asset: Имя атакующего актива (для Attack).
Пример: "Organization Moles".
target_faction: Имя файла целевой фракции без .md (для Attack).
Пример: "Harpers".
target_asset: Имя целевого актива или "BoI [[Уотердип]]" (для Attack).
Пример: "Informers".
asset_name: Имя актива (для Create Asset, Hide Asset, Sell Asset, Move Asset).
Пример: "Spymaster".
move_destination: Wikilink новой локации (для Move Asset).
Пример: "[[Невервинтер]]".
repair_asset: Имя ремонтируемого актива или "faction" (для Repair Asset).
Пример: "Infantry", "faction".
expand_location: Wikilink целевой локации (для Expand Influence).
Пример: "[[Невервинтер]]".
expand_hp: Желаемый HP нового BoI (для Expand Influence).
Допустимы: 130. По умолчанию: 5.
upgrade_attribute: Атрибут для повышения через XP (опционально).
Допустимы: "force", "wealth", "cunning".
event_description: Короткое описание события для файла события (12 предложения).
По умолчанию: генерируется автоматически.
"""
faction_file: str # имя файла без .md, обязательно
action_type: str # "Attack"|"Move Asset"|"Repair Asset"|"Expand Influence"|"Create Asset"|"Hide Asset"|"Sell Asset"
world: str # "Toril", "Krynn"...
location: str # "Waterdeep"
attacking_asset: str # для Attack
target_faction: str # имя файла цели без .md, для Attack
target_asset: str # имя целевого актива, для Attack
asset_name: str # для Create/Hide/Sell/Move Asset
move_destination: str # для Move Asset
repair_asset: str # "asset name" | "faction", для Repair Asset
expand_location: str # для Expand Influence
expand_hp: int # 130, default 5
upgrade_attribute: str # "force"|"wealth"|"cunning", опционально
event_description: str # описание события, default auto
class FactionTurnResolveRequest(TypedDict, total=False):
"""
Параметры одного вызова resolve_faction_turn.
Поля:
game_date: Игровая дата в произвольном формате.
По умолчанию: текущая реальная дата.
factions_folder: Путь к папке с файлами фракций.
По умолчанию: "Фракции".
events_folder: Путь к папке с файлами событий.
По умолчанию: "События".
actions: Список действий фракций за этот ход.
Обязательное поле. Каждый элемент — FactionAction.
"""
game_date: str # default: today
factions_folder: str # default "Фракции"
events_folder: str # default "События"
actions: list[FactionAction] # list[FactionAction]
new_goals: dict[str, dict]
# ---------------------------------------------------------------------------
# Вспомогательные функции
# ---------------------------------------------------------------------------
def _default_resolve_request() -> dict:
"""Дефолтные значения для FactionTurnResolveRequest."""
return {
"game_date": date.today().strftime("%d %B %Y"),
"factions_folder": "Фракции",
"events_folder": "События",
"actions": [],
}
def _load_faction_file(faction_file: str, factions_folder: str) -> tuple[dict, str, str]:
"""
Загружает файл фракции из vault.
Возвращает (frontmatter_dict, body_str, file_path).
Бросает ValueError если файл не найден.
"""
# 1. Пытаемся найти полный путь через кэш (ищет во всех подпапках)
path = resolve_wikilink(faction_file)
# 2. Если резолв не помог (например, файл только что создан или имя странное),
# пробуем старый метод как запасной вариант
if not path:
path = f"{factions_folder}/{faction_file}.md"
try:
data = get_file(path)
except RuntimeError:
raise ValueError(
f"Фракция не найдена: '{faction_file}'. "
f"Obsidian не смог найти путь автоматически, а по пути '{path}' файла нет."
)
fm = normalize_faction_frontmatter(data.get("frontmatter", {}))
return fm, data.get("body", ""), path
def _find_asset_in_faction(fm: dict, asset_name: str) -> Optional[dict]:
"""
Ищет актив по имени в списке активов фракции.
Возвращает ссылку на объект или None.
"""
for a in fm.get("assets", []):
if a.get("name") == asset_name:
return a
return None
def _find_boi_in_faction(fm: dict, location_wikilink: str) -> Optional[dict]:
"""
Ищет Base of Influence фракции в указанной локации.
"""
loc_norm = normalize_wikilink(location_wikilink)
for a in fm.get("assets", []):
if a.get("is_base_of_influence") and normalize_wikilink(a.get("location", "")) == loc_norm:
return a
return None
def _apply_damage_to_asset(
fm: dict,
asset_name: str,
damage: int,
) -> tuple[int, bool]:
"""
Наносит урон активу в frontmatter.
Возвращает (damage_dealt: int, asset_destroyed: bool).
Если актив уничтожен — удаляет его из списка.
Урон не уходит через 0 (overflow не передаётся).
"""
asset = _find_asset_in_faction(fm, asset_name)
if not asset:
return 0, False
current_hp = asset.get("hp", 0)
actual_damage = min(damage, current_hp)
asset["hp"] = current_hp - actual_damage
destroyed = asset["hp"] <= 0
if destroyed:
fm["assets"] = [a for a in fm["assets"] if a.get("name") != asset_name]
return actual_damage, destroyed
def _apply_damage_to_boi(
fm: dict,
boi_asset: dict,
damage: int,
) -> tuple[int, bool]:
"""
Наносит урон Base of Influence И напрямую HP фракции (WWN правило).
По WWN: урон BoI = урон HP фракции, overflow НЕ передаётся.
Возвращает (damage_dealt: int, boi_destroyed: bool).
"""
current_boi_hp = boi_asset.get("hp", 0)
actual_damage = min(damage, current_boi_hp)
boi_asset["hp"] = current_boi_hp - actual_damage
# Прямой урон HP фракции
faction_hp = fm.get("hp", 0)
fm["hp"] = max(0, faction_hp - actual_damage)
destroyed = boi_asset["hp"] <= 0
if destroyed:
boi_name = boi_asset.get("name", "")
fm["assets"] = [a for a in fm["assets"] if a.get("name") != boi_name]
if fm["hp"] <= 0:
fm["state"] = "defeated"
return actual_damage, destroyed
def _make_event_filename(game_date: str, faction_file: str, action_type: str) -> str:
"""
Генерирует читаемое имя файла события.
Формат: Date_Faction_Action.md
"""
# Извлекаем все буквы и цифры из даты, объединяем через дефис (например: 15-Mirtul-1492-DR)
safe_date_parts = re.findall(r'[a-zA-Zа-яА-Я0-9]+', game_date)
safe_date = "-".join(safe_date_parts) if safe_date_parts else date.today().isoformat()
# Заменяем все не-буквенно-цифровые символы на подчеркивания
safe_faction = re.sub(r'[^a-zA-Zа-яА-Я0-9]', '_', faction_file)
safe_faction = re.sub(r'_+', '_', safe_faction).strip('_')
safe_action = re.sub(r'[^a-zA-Zа-яА-Я0-9]', '_', action_type)
safe_action = re.sub(r'_+', '_', safe_action).strip('_')
return f"{safe_date}_{safe_faction}_{safe_action}.md"
def _make_event_content(
game_date: str,
faction_file: str,
faction_fm: dict,
action_type: str,
description: str,
mechanical_result: dict,
location: str,
) -> str:
"""
Формирует полное содержимое файла события (YAML frontmatter + тело).
"""
actor_link = f"[[{faction_file}]]"
tags = faction_fm.get("tags", [])
fm_lines = [
"---",
f'event_date: "{game_date}"',
f"actor: \"{actor_link}\"",
f'action_type: "{action_type}"',
f'location: "{location}"',
"tags:",
]
for t in tags:
fm_lines.append(f' - "{t}"')
# Механический результат
fm_lines.append("mechanical_result:")
for k, v in mechanical_result.items():
if isinstance(v, str):
fm_lines.append(f' {k}: "{v}"')
else:
fm_lines.append(f' {k}: {v}')
fm_lines.append("requires_player_attention: false")
fm_lines.append("---")
body = f"# {action_type}: {faction_file}\n\n{description}\n"
return "\n".join(fm_lines) + "\n\n" + body
# ---------------------------------------------------------------------------
# Обработчики действий
# ---------------------------------------------------------------------------
def _resolve_attack(
action: dict,
faction_fm: dict,
all_factions_data: dict,
factions_folder: str,
) -> dict:
"""
Выполняет действие Attack по правилам WWN.
Бросает 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", "")
target_asset_name = action.get("target_asset", "")
location = action.get("location", "")
result = {
"action": "Attack",
"attacker": action.get("faction_file", ""),
"attacking_asset": attacking_asset_name,
"target_faction": target_faction_file,
"target_asset": target_asset_name,
}
# Загружаем определение атакующего актива
own_asset_def = get_asset(attacking_asset_name)
if not own_asset_def:
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")
# Значения атрибутов
atk_val = faction_fm.get(atk_attr.lower(), 0)
# Загружаем цель
if target_faction_file not in all_factions_data:
result["error"] = f"Фракция-цель '{target_faction_file}' не найдена"
return result
tf_fm, tf_body, tf_path = all_factions_data[target_faction_file]
def_val = tf_fm.get(atk_vs.lower(), 0)
# Faction tags
a_extra, d_extra = get_attack_extra_die_flags(
faction_fm, tf_fm, atk_attr, own_asset_def.get("magic_required", "None")
)
antimagical = has_antimagical_penalty(tf_fm, own_asset_def.get("magic_required", "None"))
if antimagical:
a_extra = False # Antimagical: атакующий берёт худший из 2d10
# Massive автопобеда
massive_result = check_massive_auto_win(faction_fm, tf_fm, atk_attr)
if massive_result is True:
attacker_wins = True
a_roll, d_roll = 10 + atk_val, 0
result["roll_note"] = "AUTO-WIN (Massive)"
elif massive_result is False:
attacker_wins = False
a_roll, d_roll = 0, 10 + def_val
result["roll_note"] = "AUTO-LOSS (target Massive)"
else:
if antimagical:
# Бросаем 2d10 и берём ХУДШИЙ
r1 = random.randint(1, 10)
r2 = random.randint(1, 10)
a_base = min(r1, r2)
elif a_extra:
r1 = random.randint(1, 10)
r2 = random.randint(1, 10)
a_base = max(r1, r2)
else:
a_base = random.randint(1, 10)
if d_extra:
r1 = random.randint(1, 10)
r2 = random.randint(1, 10)
d_base = max(r1, r2)
else:
d_base = random.randint(1, 10)
a_roll = a_base + atk_val
d_roll = d_base + def_val
attacker_wins = a_roll > d_roll # строго больше
result["attacker_roll"] = a_roll
result["defender_roll"] = d_roll
result["attacker_wins"] = attacker_wins
if attacker_wins:
# Special attack: не бросаем обычный урон
if atk_dmg_notation == "Special":
damage = 0
result["damage"] = 0
result["note"] = "Special attack effect — применить вручную"
else:
damage = roll_dice(atk_dmg_notation)
result["damage"] = damage
# Определяем цель — BoI или обычный актив
is_boi_target = target_asset_name.startswith("BoI") or \
"Base of Influence" in target_asset_name
if is_boi_target:
# Ищем BoI в локации
boi = _find_boi_in_faction(tf_fm, location)
if boi:
dmg_dealt, destroyed = _apply_damage_to_boi(tf_fm, boi, damage)
result["damage_dealt"] = dmg_dealt
result["boi_destroyed"] = destroyed
result["target_faction_hp_after"] = tf_fm.get("hp", 0)
if destroyed:
result["note"] = f"BoI уничтожен! Фракция {target_faction_file} потеряла {dmg_dealt} HP"
if tf_fm.get("state") == "defeated":
result["faction_defeated"] = target_faction_file
else:
dmg_dealt, destroyed = _apply_damage_to_asset(tf_fm, target_asset_name, damage)
result["damage_dealt"] = dmg_dealt
result["asset_destroyed"] = destroyed
if destroyed:
result["note"] = f"Актив '{target_asset_name}' уничтожен!"
else:
# Контратака
target_def = get_asset(target_asset_name)
ctr_notation = target_def.get("counterattack") if target_def else None
if ctr_notation:
ctr_damage = roll_dice(ctr_notation)
result["counterattack_damage"] = ctr_damage
# Zealot tag: при провале может перебросить, но получает контратаку
if "Zealot" in faction_fm.get("faction_tags", []):
result["zealot_note"] = "Zealot: может перебросить (применить вручную), но контратака уже нанесена"
own_asset_ref = _find_asset_in_faction(faction_fm, attacking_asset_name)
if own_asset_ref:
ctr_dealt, own_destroyed = _apply_damage_to_asset(
faction_fm, attacking_asset_name, ctr_damage
)
result["counterattack_dealt"] = ctr_dealt
result["attacking_asset_destroyed"] = own_destroyed
else:
result["counterattack_damage"] = 0
result["note"] = "Цель не имеет counterattack"
return result
def _resolve_create_asset(
action: dict,
faction_fm: dict,
location_wikilink: str,
) -> dict:
"""
Выполняет действие Create Asset по правилам WWN.
Проверяет все условия: атрибут, magic, treasure, лимит, BoI.
Добавляет актив в список и снимает Treasure.
"""
asset_name = action.get("asset_name", "")
result = {"action": "Create Asset", "asset": asset_name}
asset_def = get_asset(asset_name)
if not asset_def:
result["error"] = f"Актив '{asset_name}' не найден в таблицах WWN"
return result
attr = asset_def["attribute"]
tier_req = asset_def["tier_required"]
cost = asset_def["cost"]
# Проверки
if faction_fm.get(attr.lower(), 0) < tier_req:
result["error"] = f"Недостаточный {attr} ({faction_fm.get(attr.lower(), 0)} < {tier_req})"
return result
magic_level = MAGIC_LEVELS.get(faction_fm.get("magic", "None"), 0)
req_magic_level = MAGIC_LEVELS.get(asset_def["magic_required"], 0)
if magic_level < req_magic_level:
result["error"] = f"Недостаточный Magic уровень ({faction_fm.get('magic')} < {asset_def['magic_required']})"
return result
if faction_fm.get("treasure", 0) < cost:
result["error"] = f"Недостаточно Treasure ({faction_fm.get('treasure', 0)} < {cost})"
return result
# Лимит активов атрибута
attr_count = sum(
1 for a in faction_fm.get("assets", [])
if not a.get("is_base_of_influence") and a.get("attribute") == attr
)
attr_limit = faction_fm.get(attr.lower(), 0)
if attr_count >= attr_limit:
result["error"] = f"Лимит {attr}-активов достигнут ({attr_count}/{attr_limit})"
return result
# BoI проверка
loc_norm = normalize_wikilink(location_wikilink)
has_boi = any(
a.get("is_base_of_influence") and normalize_wikilink(a.get("location", "")) == loc_norm
for a in faction_fm.get("assets", [])
)
if not has_boi:
result["error"] = f"Нет Base of Influence в {location_wikilink}"
return result
# Уже куплен?
if any(a.get("name") == asset_name for a in faction_fm.get("assets", [])):
result["error"] = f"Актив '{asset_name}' уже есть у фракции"
return result
# Применяем
new_asset = {
"name": asset_name,
"is_base_of_influence": False,
"attribute": attr,
"tier_required": tier_req,
"cost": cost,
"hp": asset_def["hp_max"],
"hp_max": asset_def["hp_max"],
"location": location_wikilink,
"stealthed": asset_def.get("special_key") in (
"occult_infiltrators_start_stealthed",
"summoned_hunter_start_stealthed",
"demonic_slayer_start_stealthed",
),
"subtle": "Subtle" in asset_def.get("qualities", []),
}
faction_fm["assets"].append(new_asset)
faction_fm["treasure"] = faction_fm.get("treasure", 0) - cost
# Добавляем регион в список regions, если его там нет
loc_norm = normalize_wikilink(location_wikilink)
current_regions = faction_fm.get("regions", [])
if not any(normalize_wikilink(r) == loc_norm for r in current_regions):
current_regions.append(location_wikilink)
faction_fm["regions"] = current_regions
result["treasure_spent"] = cost
result["treasure_after"] = faction_fm["treasure"]
result["asset_hp"] = asset_def["hp_max"]
return result
def _resolve_hide_asset(
action: dict,
faction_fm: dict,
all_factions_data: dict,
own_faction_file: str,
) -> dict:
"""
Выполняет действие Hide Asset по правилам WWN.
Условия: Cunning >= 3, стоимость 2 Treasure за актив,
нельзя скрывать в локации с вражеским 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:
result["error"] = "Cunning < 3 — Hide Asset недоступно"
return result
if faction_fm.get("treasure", 0) < 2:
result["error"] = f"Недостаточно Treasure (нужно 2, есть {faction_fm.get('treasure', 0)})"
return result
asset = _find_asset_in_faction(faction_fm, asset_name)
if not asset:
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
result["treasure_after"] = faction_fm["treasure"]
return result
def _resolve_move_asset(action: dict, faction_fm: dict) -> dict:
"""
Выполняет действие Move Asset по правилам WWN.
Обновляет location актива.
Если актив не Subtle/Stealthed и перемещается во враждебную локацию —
помечает предупреждением (проверку враждебности делает LLM/ГМ).
"""
asset_name = action.get("asset_name", "")
destination = action.get("move_destination", "")
result = {"action": "Move Asset", "asset": asset_name, "destination": destination}
asset = _find_asset_in_faction(faction_fm, asset_name)
if not asset:
result["error"] = f"Актив '{asset_name}' не найден"
return result
old_location = asset.get("location", "?")
asset["location"] = destination
result["from"] = old_location
result["to"] = destination
if not asset.get("subtle") and not asset.get("stealthed"):
result["note"] = (
"Актив не Subtle/Stealthed — при перемещении во враждебную локацию "
"может быть заблокирован. Проверьте наличие вражеского BoI в destination."
)
return result
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 < cost:
result["error"] = f"Недостаточно Treasure (нужно {cost}, есть {treasure})"
return result
repair_info = calc_faction_repair_heal(faction_fm)
heal = repair_info["heal"]
old_hp = faction_fm.get("hp", 0)
hp_max = faction_fm.get("hp_max", 1)
faction_fm["hp"] = min(hp_max, old_hp + heal)
faction_fm["treasure"] = treasure - cost
result["healed"] = faction_fm["hp"] - old_hp
result["hp_after"] = faction_fm["hp"]
result["treasure_spent"] = cost
else:
# Проверяем, является ли цель Base of Influence
asset = None
if repair_target.startswith("Base of Influence"):
location_from_action = action.get("location", "")
if location_from_action:
asset = _find_boi_in_faction(faction_fm, location_from_action)
if not asset:
for a in faction_fm.get("assets", []):
if a.get("is_base_of_influence") and (repair_target in a.get("name", "") or a.get("name", "").startswith("Base of Influence")):
asset = a
break
else:
asset = _find_asset_in_faction(faction_fm, repair_target)
if not asset:
result["error"] = f"Актив '{repair_target}' не найден"
return result
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)
faction_fm["treasure"] = treasure - cost
result["healed"] = asset["hp"] - old_hp
result["hp_after"] = asset["hp"]
result["treasure_spent"] = cost
result["treasure_after"] = faction_fm.get("treasure", 0)
return result
def _resolve_expand_influence(
action: dict,
faction_fm: dict,
all_factions_data: dict,
) -> dict:
"""
Выполняет действие Expand Influence по правилам WWN.
Создаёт новый BoI в expand_location за expand_hp Treasure.
Затем выполняет Cunning vs Cunning против каждой фракции с любым активом в локации.
При проигрыше — враг получает право на немедленную атаку (помечается в отчёте).
"""
expand_loc = action.get("expand_location", action.get("location", ""))
desired_hp = action.get("expand_hp", 5)
result = {
"action": "Expand Influence",
"location": expand_loc,
"boi_hp": desired_hp,
}
loc_norm = normalize_wikilink(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:
result["error"] = f"Недостаточно Treasure (нужно {cost}, есть {treasure})"
return result
# ----- Создаём BoI -----
boi_name = f"Base of Influence — {expand_loc.strip('[]')}"
new_boi = {
"name": boi_name,
"is_base_of_influence": True,
"hp": desired_hp,
"hp_max": desired_hp,
"location": expand_loc,
"cost": cost,
"stealthed": False,
"subtle": False,
}
faction_fm["assets"].append(new_boi)
faction_fm["treasure"] = treasure - cost
result["treasure_spent"] = cost
result["treasure_after"] = faction_fm["treasure"]
result["boi_created"] = boi_name
# ----- Добавляем локацию в список 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():
# Пропускаем себя
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_asset:
continue
tf_cunning = tf_fm.get("cunning", 0)
a_roll, d_roll, attacker_wins = roll_attribute_check(own_cunning, tf_cunning)
contesting_checks.append({
"contesting_faction": tf_file,
"our_roll": a_roll,
"their_roll": d_roll,
"we_win": attacker_wins,
"note": "OK — BoI создан без противодействия" if attacker_wins
else f"⚠️ {tf_file} может немедленно атаковать новый BoI!"
})
result["cunning_checks"] = contesting_checks
return result
def _resolve_sell_asset(action: dict, faction_fm: dict) -> dict:
"""
Выполняет действие Sell Asset по правилам WWN.
Если актив не повреждён — получить floor(cost/2) Treasure.
Если повреждён — 0 Treasure.
"""
asset_name = action.get("asset_name", "")
result = {"action": "Sell Asset", "asset": asset_name}
asset = _find_asset_in_faction(faction_fm, asset_name)
if not asset:
result["error"] = f"Актив '{asset_name}' не найден"
return result
asset_def = get_asset(asset_name)
hp = asset.get("hp", 0)
hp_max = asset.get("hp_max", 1)
if hp < hp_max:
gain = 0
result["note"] = "Актив повреждён — получено 0 Treasure"
else:
gain = math.floor((asset_def["cost"] if asset_def else 0) / 2)
faction_fm["assets"] = [a for a in faction_fm["assets"] if a.get("name") != asset_name]
faction_fm["treasure"] = faction_fm.get("treasure", 0) + gain
result["treasure_gained"] = gain
result["treasure_after"] = faction_fm["treasure"]
return result
def _resolve_upgrade_attribute(action: dict, faction_fm: dict) -> Optional[dict]:
"""
Применяет повышение атрибута через XP (если указан upgrade_attribute).
Возвращает dict с результатом или None если повышения нет.
"""
attr = action.get("upgrade_attribute", "")
if not attr:
return None
current = faction_fm.get(attr, 0)
cost = calc_xp_cost_to_raise(current)
if cost is None:
return {"action": "XP Upgrade", "error": f"{attr} уже на максимуме (8)"}
current_xp = faction_fm.get("xp", 0)
if current_xp < cost:
return {
"action": "XP Upgrade",
"error": f"Недостаточно XP ({current_xp} < {cost}) для повышения {attr}"
}
faction_fm[attr] = current + 1
faction_fm["xp"] = current_xp - cost
# Пересчитываем hp_max
new_hp_max = calc_hp_max(
faction_fm.get("force", 0),
faction_fm.get("wealth", 0),
faction_fm.get("cunning", 0),
)
faction_fm["hp_max"] = new_hp_max
return {
"action": "XP Upgrade",
"attribute": attr,
"old_value": current,
"new_value": current + 1,
"xp_spent": cost,
"xp_after": faction_fm["xp"],
"new_hp_max": new_hp_max,
}
# ---------------------------------------------------------------------------
# Автоматическое обновление прогресса WWN-целей
# ---------------------------------------------------------------------------
def update_wwn_goal_progress(fm: dict, action: dict, result: dict) -> None:
"""
Обновляет прогресс активной wwn_goal на основе выполненного действия.
Вызывается после каждого действия.
"""
wwn = fm.get("wwn_goal")
if not wwn or wwn.get("status") != "active":
return
goal_type = wwn.get("type")
condition = wwn.get("condition", {})
progress = wwn.get("progress", 0)
if goal_type == "Blood the Enemy":
# При успешной атаке добавляем нанесённый урон
if action.get("action_type") == "Attack" and result.get("attacker_wins"):
damage = result.get("damage_dealt", 0)
wwn["progress"] = progress + damage
elif goal_type == "Expand Influence":
# При создании BoI
if action.get("action_type") == "Expand Influence" and result.get("boi_created"):
wwn["progress"] = 1
# Другие типы целей можно добавить позже
# Для целей с таймером (Peaceable Kingdom) нужна отдельная проверка в конце хода
def check_goal_completion(fm: dict) -> bool:
"""
Проверяет, достигла ли активная цель порога.
Возвращает True, если цель выполнена (тогда начисляет XP и меняет статус).
"""
wwn = fm.get("wwn_goal")
if not wwn or wwn.get("status") != "active":
return False
goal_type = wwn.get("type")
condition = wwn.get("condition", {})
progress = wwn.get("progress", 0)
completed = False
if goal_type == "Blood the Enemy":
target = condition.get("target", 0)
completed = progress >= target
elif goal_type == "Expand Influence":
completed = progress >= 1
# Другие типы
if completed:
wwn["status"] = "completed"
xp_gain = wwn.get("xp_reward", 0)
fm["xp"] = fm.get("xp", 0) + xp_gain
return True
return False
# ---------------------------------------------------------------------------
# Главная функция
# ---------------------------------------------------------------------------
def resolve_faction_turn(
debug_description: str,
requests: list[FactionTurnResolveRequest],
) -> str:
"""
Основной метод, который вызывает тул с фронта.
Принимает список запросов на применение хода фракций.
Для каждого запроса:
- загружает все затронутые файлы фракций из Obsidian vault;
- применяет каждое действие по правилам WWN:
Attack → attribute check 1d10+attr vs 1d10+attr, урон активам/BoI;
Create Asset → проверки + создание актива;
Hide Asset → стоимость 2 Treasure, stealth = true;
Move Asset → обновление location;
Repair Asset → heal по формуле WWN;
Expand Influence → создание BoI + cunning checks;
Sell Asset → получение Treasure;
- накапливает все изменения в памяти (TODO: сейчас применяются разом;
можно изменить на немедленное применение по порядку инициативы);
- записывает изменённые файлы фракций обратно в vault;
- создаёт файлы событий в events_folder;
- формирует сухой отчёт об изменениях для LLM-хроникёра.
Возвращает Markdown-строку с отчётом.
"""
if not requests:
requests = [{}]
all_sections: list[str] = []
for raw_req in requests:
req = {**_default_resolve_request(), **{k: v for k, v in raw_req.items() if v is not None}}
game_date = req["game_date"]
factions_folder = req["factions_folder"]
events_folder = req["events_folder"]
actions: list[dict] = req.get("actions", [])
if not actions:
all_sections.append("## resolve_faction_turn\n\n*(Нет действий для применения)*")
continue
# ── TODO: [ПОДУМАТЬ] Сейчас все изменения накапливаются в памяти и применяются разом.
# Альтернатива: применять немедленно по порядку инициативы (порядок action в списке).
# Текущий подход: загружаем все файлы → применяем все дельты → пишем все файлы.
# Плюс: нет частичных состояний при ошибке в середине.
# Минус: фракция, атакованная второй, не "знает" об уроне от первой атаки.
# Шаг 1: Загружаем все затронутые фракции
all_factions_data: dict[str, tuple[dict, str, str]] = {} # file → (fm, body, path)
load_errors: list[str] = []
faction_files_needed = set()
for action in actions:
# Обработка location через symspellpy
world = action.get("world", "Toril")
raw_loc = action.get("location", "")
if raw_loc:
clean_loc = raw_loc.strip("[]")
matched_loc = match_location(world, clean_loc)
action["location"] = f"[[{matched_loc}]]"
raw_exp_loc = action.get("expand_location", "")
if raw_exp_loc:
clean_exp_loc = raw_exp_loc.strip("[]")
matched_exp_loc = match_location(world, clean_exp_loc)
action["expand_location"] = f"[[{matched_exp_loc}]]"
faction_files_needed.add(action.get("faction_file", ""))
if action.get("action_type") == "Attack":
faction_files_needed.add(action.get("target_faction", ""))
for ff in faction_files_needed:
if not ff:
continue
try:
fm, body, path = _load_faction_file(ff, factions_folder)
all_factions_data[ff] = (fm, body, path)
except ValueError as e:
load_errors.append(str(e))
# Шаг 1.5: Начисление дохода и вычет апкипа перед выполнением действий
# Это гарантирует, что фракция получит средства текущего хода до того, как начнет их тратить.
factions_processed_for_income = set()
for action in actions:
ff = action.get("faction_file", "")
if ff in all_factions_data and ff not in factions_processed_for_income:
fm, _, _ = all_factions_data[ff]
# Расчет дохода по формуле WWN
income = calc_income(
fm.get("wealth", 1),
fm.get("force", 1),
fm.get("cunning", 1)
)
# Расчет обязательных выплат (апкпипа)
upkeep = calc_asset_upkeep(fm)
# Обновление казны в памяти
fm["treasure"] = fm.get("treasure", 0) + income - upkeep["total_upkeep"]
fm["treasure"] = max(0, fm["treasure"])
# Помечаем, чтобы не начислять доход дважды, если у фракции несколько действий
factions_processed_for_income.add(ff)
if load_errors:
all_sections.append("## ❌ Ошибки загрузки\n" + "\n".join(f"- {e}" for e in load_errors))
# Шаг 2: Применяем действия
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", "")
location = action.get("location", "")
if faction_file not in all_factions_data:
action_results.append({
"action": action_type,
"faction": faction_file,
"error": f"Фракция '{faction_file}' не была загружена",
})
continue
fm, body, path = all_factions_data[faction_file]
result = {"faction": faction_file}
# ----- Для 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, all_factions_data, faction_file))
elif action_type == "Move Asset":
result.update(_resolve_move_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))
else:
result["error"] = f"Неизвестный action_type: '{action_type}'"
# Повышение атрибута через XP (опционально для любого действия)
upgrade_result = _resolve_upgrade_attribute(action, fm)
if upgrade_result:
result["xp_upgrade"] = upgrade_result
# Обновляем last_action
fm["last_turn_date"] = game_date
fm["last_action"] = f"{action_type}: {action.get('attacking_asset') or action.get('asset_name') or ''}"
action_results.append(result)
# Обновляем прогресс цели после каждого действия
update_wwn_goal_progress(fm, action, result)
# Проверяем завершение целей после всех действий
for ff, (fm, body, path) in all_factions_data.items():
if check_goal_completion(fm):
# Можно добавить лог о выполнении
pass
# Шаг 2.5: Обработка new_goals (полная замена)
new_goals = req.get("new_goals", {})
for faction_file, goal_update in new_goals.items():
if faction_file not in all_factions_data:
continue
fm, body, path = all_factions_data[faction_file]
# 1. Обновление global_goals (если поле присутствует)
if "global_goals" in goal_update:
new_list = goal_update["global_goals"]
# Используем clean_global_goals из faction_utils
from faction_utils import clean_global_goals
fm["global_goals"] = clean_global_goals(new_list)
# 2. Обновление wwn_goal (если поле присутствует)
if "wwn_goal" in goal_update:
new_goal = goal_update["wwn_goal"]
if new_goal is None:
fm["wwn_goal"] = None
else:
# Создаём новый объект цели
goal_type = new_goal.get("type")
if goal_type:
from wwn_mechanics import get_xp_reward_for_goal
fm["wwn_goal"] = {
"type": goal_type,
"xp_reward": get_xp_reward_for_goal(goal_type, fm),
"status": "active",
"condition": new_goal.get("condition", {}),
"progress": 0
}
# если тип отсутствует, ничего не делаем (оставляем старую цель)
# Шаг 3: Записываем все изменённые файлы фракций в vault
write_errors: list[str] = []
for ff, (fm, body, path) in all_factions_data.items():
try:
success = write_faction(path, fm, body)
if not success:
write_errors.append(f"Не удалось записать {path}")
except Exception as e:
write_errors.append(f"Ошибка записи {path}: {e}")
# Шаг 4: Создаём файлы событий
for action, result in zip(actions, action_results):
if "error" in result:
continue
faction_file = action.get("faction_file", "")
action_type = action.get("action_type", "")
location = action.get("location", "")
if faction_file not in all_factions_data:
continue
fm, body, path = all_factions_data[faction_file]
event_description = action.get("event_description") or (
f"{faction_file} выполнил {action_type}"
+ (f" против {action.get('target_faction', '')}" if action.get("target_faction") else "")
+ f" в {location}."
)
# Механический результат для frontmatter события
mechanical_result = {
k: v for k, v in result.items()
if k not in ("faction",) and not isinstance(v, (dict, list))
}
event_filename = _make_event_filename(game_date, faction_file, action_type)
event_path = f"{events_folder}/{event_filename}"
event_content = _make_event_content(
game_date=game_date,
faction_file=faction_file,
faction_fm=fm,
action_type=action_type,
description=event_description,
mechanical_result=mechanical_result,
location=location,
)
try:
success = create_event_file(event_path, event_content)
if success:
event_files_created.append(event_path)
except Exception as e:
write_errors.append(f"Ошибка создания события {event_path}: {e}")
# Шаг 5: Формируем отчёт
report_lines = [
f"## 📋 Отчёт хода фракций | {game_date}\n",
]
for result in action_results:
faction = result.get("faction", "?")
action_type = result.get("action", "?")
icon = "" if "error" not in result else ""
report_lines.append(f"### {icon} {faction}{action_type}")
if "error" in result:
report_lines.append(f"**Ошибка:** {result['error']}\n")
continue
# Детали по типу действия
if action_type == "Attack":
target = result.get("target_asset", "?")
tf = result.get("target_faction", "?")
a_roll = result.get("attacker_roll", "?")
d_roll = result.get("defender_roll", "?")
wins = result.get("attacker_wins", False)
outcome = "🗡️ Успех" if wins else "🛡️ Провал"
report_lines.append(
f"- Атака: `{result.get('attacking_asset')}` → `{target}` [{tf}]\n"
f"- Бросок: {a_roll} vs {d_roll} → **{outcome}**"
)
if wins:
dmg = result.get("damage_dealt", result.get("damage", "?"))
report_lines.append(f"- Урон: {dmg} HP")
if result.get("asset_destroyed"):
report_lines.append(f"- 💥 `{target}` **уничтожен**!")
if result.get("boi_destroyed"):
report_lines.append(f"- 💥 **BoI уничтожен!** Фракция {tf} теряет HP напрямую")
if result.get("faction_defeated"):
report_lines.append(f"- ☠️ **Фракция {result['faction_defeated']} УНИЧТОЖЕНА!**")
else:
ctr = result.get("counterattack_dealt", 0)
if ctr:
report_lines.append(f"- Контратака: {ctr} HP урона по `{result.get('attacking_asset')}`")
if result.get("attacking_asset_destroyed"):
report_lines.append(f"- 💥 `{result.get('attacking_asset')}` **уничтожен контратакой**!")
if result.get("note"):
report_lines.append(f"- ⚠️ {result['note']}")
elif action_type == "Create Asset":
report_lines.append(
f"- Создан: `{result.get('asset')}` [HP {result.get('asset_hp')}]\n"
f"- Потрачено: {result.get('treasure_spent')} Treasure → осталось {result.get('treasure_after')}"
)
elif action_type == "Hide Asset":
report_lines.append(
f"- `{result.get('asset')}` теперь **Stealthed**\n"
f"- Потрачено: {result.get('treasure_spent')} Treasure → осталось {result.get('treasure_after')}"
)
elif action_type == "Move Asset":
report_lines.append(
f"- `{result.get('asset')}`: {result.get('from')}{result.get('to')}"
)
if result.get("note"):
report_lines.append(f"- ⚠️ {result['note']}")
elif action_type == "Repair Asset":
report_lines.append(
f"- `{result.get('target')}` восстановлен на {result.get('healed')} HP "
f"[HP после: {result.get('hp_after')}]\n"
f"- Потрачено: {result.get('treasure_spent')} Treasure → осталось {result.get('treasure_after')}"
)
elif action_type == "Expand Influence":
report_lines.append(
f"- Создан **{result.get('boi_created')}** [HP {result.get('boi_hp')}]\n"
f"- Потрачено: {result.get('treasure_spent')} Treasure → осталось {result.get('treasure_after')}"
)
for check in result.get("cunning_checks", []):
icon_c = "" if check["we_win"] else "⚠️"
report_lines.append(
f" - {icon_c} vs {check['contesting_faction']}: "
f"{check['our_roll']} vs {check['their_roll']}{check['note']}"
)
elif action_type == "Sell Asset":
report_lines.append(
f"- `{result.get('asset')}` продан за {result.get('treasure_gained')} Treasure\n"
f"- Treasure после: {result.get('treasure_after')}"
)
# XP upgrade
xp_upg = result.get("xp_upgrade")
if xp_upg and "error" not in xp_upg:
attr = xp_upg.get("attribute", "?")
report_lines.append(
f"- 🌟 **XP Upgrade:** {attr} {xp_upg['old_value']}{xp_upg['new_value']} "
f"(потрачено {xp_upg['xp_spent']} XP, осталось {xp_upg['xp_after']})"
)
report_lines.append("")
# Записанные файлы
if event_files_created:
report_lines.append("### 📄 Созданы файлы событий")
for ep in event_files_created:
report_lines.append(f"- `{ep}`")
report_lines.append("")
if write_errors:
report_lines.append("### ⚠️ Ошибки записи")
for we in write_errors:
report_lines.append(f"- {we}")
all_sections.append("\n".join(report_lines))
return "\n\n---\n\n".join(all_sections)
# ---------------------------------------------------------------------------
# Точка входа для тестирования
# ---------------------------------------------------------------------------
if __name__ == "__main__":
result = resolve_faction_turn(
"тест resolve_faction_turn",
[
{
"game_date": "15 Mirtul 1492 DR",
"factions_folder": "Фракции",
"events_folder": "События",
"actions": [
{
"faction_file": "Cult_of_the_Dragon",
"action_type": "Attack",
"attacking_asset": "Organization Moles",
"target_faction": "Harpers",
"target_asset": "Informers",
"location": "[[Waterdeep]]",
"event_description": "Агенты Культа нанесли удар по агентурной сети Харперов.",
},
{
"faction_file": "Harpers",
"action_type": "Create Asset",
"asset_name": "Spymaster",
"location": "[[Waterdeep]]",
},
],
},
{
# Минимальный запрос — только дефолты
"actions": [
{
"faction_file": "Shadow_Thieves",
"action_type": "Hide Asset",
"asset_name": "Smugglers",
"location": "[[Waterdeep]]",
}
],
},
],
)
print(result)