1281 lines
53 KiB
Python
1281 lines
53 KiB
Python
"""
|
||
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).
|
||
Допустимы: 1–30. По умолчанию: 5.
|
||
|
||
upgrade_attribute: Атрибут для повышения через XP (опционально).
|
||
Допустимы: "force", "wealth", "cunning".
|
||
|
||
event_description: Короткое описание события для файла события (1–2 предложения).
|
||
По умолчанию: генерируется автоматически.
|
||
"""
|
||
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 # 1–30, 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,
|
||
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:
|
||
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,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Главная функция
|
||
# ---------------------------------------------------------------------------
|
||
|
||
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] = []
|
||
|
||
# ---- В начале цикла обработки действий инициализируем счётчики ремонта ----
|
||
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)
|
||
|
||
# Шаг 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) |