""" 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] 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" 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, } # --------------------------------------------------------------------------- # Автоматическое обновление прогресса 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: 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)