dnd5-scripts/faction_turns/resolve_faction_turn.py
2026-07-06 01:48:00 +03:00

1222 lines
50 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 (
vault_query,
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
# ---------------------------------------------------------------------------
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".
Обязательное поле.
location: Локация на русском в формате Obsidian wikilink.
Обязательное поле.
Пример: "[[Уотердип]]", "[[Невервинтер]]", название локации на русском.
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"
location: str # "[[Уотердип]]"
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]
# ---------------------------------------------------------------------------
# Вспомогательные функции
# ---------------------------------------------------------------------------
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"
data = vault_query("get_frontmatter", {"path": path})
if "error" in data:
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:
"""
Генерирует имя файла события.
Формат: YYYY-MM-DD_{faction}_{action}.md
Если game_date не в ISO формате — использует реальную дату.
"""
safe_date = re.sub(r'[^\d\-]', '-', game_date)[:10]
if len(safe_date) < 10:
safe_date = date.today().isoformat()
safe_faction = re.sub(r'[^a-zA-Z0-9_]', '_', faction_file)
safe_action = re.sub(r'[^a-zA-Z0-9_]', '_', action_type)
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, # faction_file → (fm, body, path)
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
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) -> dict:
"""
Выполняет действие Hide Asset по правилам WWN.
Условия: Cunning >= 3, стоимость 2 Treasure за актив,
нельзя скрывать в локации с вражеским BoI.
"""
asset_name = action.get("asset_name", "")
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
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) -> dict:
"""
Выполняет действие Repair Asset по правилам WWN.
repair_asset = "faction" → ремонтирует HP самой фракции.
repair_asset = имя актива → ремонтирует актив.
"""
repair_target = action.get("repair_asset", "")
result = {"action": "Repair Asset", "target": repair_target}
treasure = faction_fm.get("treasure", 0)
if repair_target == "faction":
if treasure < 1:
result["error"] = "Недостаточно 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)
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:
asset = _find_asset_in_faction(faction_fm, repair_target)
if not asset:
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
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 против каждой фракции с BoI в локации.
При проигрыше — враг получает право на немедленную атаку (помечается в отчёте).
"""
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,
}
# Получаем текущий список (если его нет, создаем пустой)
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)
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_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
# 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
for a in tf_fm.get("assets", [])
)
if not tf_has_boi:
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,
}
# ---------------------------------------------------------------------------
# Главная функция
# ---------------------------------------------------------------------------
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:
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))
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] = []
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}
if 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))
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))
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)
# Шаг 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)