""" propose_faction_turn.py — Тул 1 системы фракций WWN. Читает все фракции из Obsidian-хранилища (через бэкенд-прокси vault_query), бросает инициативу, начисляет доход, проверяет апкип и для каждой активной фракции формирует полный список механически допустимых действий с числами. Экспортирует функцию propose_faction_turn(debug_description, requests), которую вызывает тул с фронта. requests — список объектов вида: { "location_wikilink": "[[Waterdeep]]", # локация для хода (обязательно) "factions_folder": "Фракции", # папка фракций (default) "events_folder": "События", # папка событий (default) "game_date": "15 Mirtul 1492", # игровая дата (default: today) "max_events": 5, # контекстных событий для LLM (default 5) } Возвращает строку в формате Markdown — механический промпт для LLM. """ import random import math from datetime import date from typing import TypedDict try: from typing_extensions import TypedDict except ImportError: from typing import TypedDict from faction_utils import ( vault_query, get_all_factions, get_factions_in_location, get_assets_in_location, get_recent_events, normalize_wikilink, roll_dice, roll_attribute_check, are_within_one_move, ) from wwn_assets_data import ( get_available_assets, get_asset, MAGIC_LEVELS, ) from wwn_mechanics import ( calc_income, calc_hp_max, calc_asset_upkeep, calc_repair_heal, calc_faction_repair_heal, calc_expand_influence_cost, get_available_upgrades, get_attack_extra_die_flags, has_antimagical_penalty, check_massive_auto_win, format_faction_status, format_asset_line, ) # --------------------------------------------------------------------------- # TypedDict # --------------------------------------------------------------------------- class FactionTurnProposalRequest(TypedDict, total=False): """ Параметры одного запроса на генерацию хода фракций. Поля: location_wikilink: Локация на русском в формате Obsidian wikilink. Обязательное поле. Пример: "[[Уотердип]]", "[[Невервинтер]]", название локации на русском. factions_folder: Путь к папке с файлами фракций внутри vault. По умолчанию: "Фракции". events_folder: Путь к папке с файлами событий внутри vault. По умолчанию: "События". game_date: Игровая дата в произвольном формате. По умолчанию: текущая реальная дата. Пример: "15 Mirtul 1492 DR". max_events: Количество последних событий для контекста LLM. Допустимы: 1–20. По умолчанию: 5. """ location_wikilink: str # wikilink локации, обязательно: "[[Уотердип]]" factions_folder: str # default "Фракции" events_folder: str # default "События" game_date: str # default: today max_events: int # 1–20, default 5 # --------------------------------------------------------------------------- # Вспомогательные функции # --------------------------------------------------------------------------- def _get_default_request() -> FactionTurnProposalRequest: """Возвращает запрос с дефолтными значениями.""" return { "location_wikilink": "[[Unknown Location]]", "factions_folder": "Фракции", "events_folder": "События", "game_date": date.today().strftime("%d %B %Y"), "max_events": 5, } def _merge_with_defaults(req: dict) -> dict: """Заполняет отсутствующие поля дефолтами.""" defaults = _get_default_request() return {**defaults, **{k: v for k, v in req.items() if v is not None}} def _roll_initiative() -> int: """ Бросает инициативу по правилам WWN: 1d8. Возвращает число 1–8. """ return random.randint(1, 8) def _get_faction_state_label(faction_fm: dict) -> str: """ Возвращает читаемый статус фракции с учётом HP. По WWN нет формальных state-меток, но добавляем для LLM: HP 100% → active HP 50–99% → damaged HP 1–49% → critical HP 0 → defeated """ hp = faction_fm.get("hp", 0) hp_max = faction_fm.get("hp_max", 1) state = faction_fm.get("state", "active") if state == "defeated": return "defeated" ratio = hp / max(hp_max, 1) if ratio >= 1.0: return "active" if ratio >= 0.5: return "damaged" if ratio > 0: return "critical" return "defeated" def _build_attack_options( faction_fm: dict, all_factions: list[dict], location_wikilink: str, ) -> list[str]: """ Формирует список строк с вариантами атаки для данной фракции. Для каждой пары (атакующий актив → цель в локации) строит: - формулу броска с учётом faction tags - возможный урон - возможный контрурон Возвращает список Markdown-строк. """ loc_norm = normalize_wikilink(location_wikilink) lines = [] # Активы атакующей фракции в локации с attack_attribute != None own_assets = [ a for a in faction_fm.get("assets", []) if not a.get("is_base_of_influence") and normalize_wikilink(a.get("location", "")) == loc_norm and not a.get("stealthed") # Stealthed активы теряют Stealth при атаке ] for own_asset in own_assets: asset_def = get_asset(own_asset["name"]) if not asset_def: continue if not asset_def.get("attack_attribute"): continue # Актив не атакует atk_attr = asset_def["attack_attribute"] atk_vs = asset_def["attack_vs"] atk_dmg = asset_def["attack_damage"] atk_val = faction_fm.get(atk_attr.lower(), 0) # Цели: активы и BoI других фракций в той же локации for target_faction in all_factions: tf_fm = target_faction.get("frontmatter", {}) tf_name = target_faction.get("name", "Unknown") # Не атакуем себя if normalize_wikilink(tf_fm.get("location", "")) == normalize_wikilink( faction_fm.get("location", "____") ) and tf_name == faction_fm.get("name"): continue target_assets = [ a for a in tf_fm.get("assets", []) if normalize_wikilink(a.get("location", "")) == loc_norm ] if not target_assets: continue for target_asset in target_assets: ta_name = target_asset["name"] is_boi = target_asset.get("is_base_of_influence", False) # Атрибут защиты 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, asset_def.get("magic_required", "None") ) antimagical = has_antimagical_penalty( tf_fm, asset_def.get("magic_required", "None") ) massive_result = check_massive_auto_win(faction_fm, tf_fm, atk_attr) # Формула броска a_dice = "2d10 best" if a_extra else "1d10" d_dice = "2d10 best" if d_extra else "1d10" if antimagical: a_dice = "2d10 WORST (Antimagical)" if massive_result is True: roll_str = f"AUTO-WIN (Massive tag)" elif massive_result is False: roll_str = f"AUTO-LOSS (target Massive tag)" else: roll_str = ( f"{a_dice}+{atk_attr}({atk_val}) vs " f"{d_dice}+{atk_vs}({def_val})" ) # Counterattack ta_def = get_asset(ta_name) ctr_dmg = ta_def.get("counterattack") if ta_def else None ctr_str = f", fail → {ctr_dmg} to {own_asset['name']}" if ctr_dmg else ", fail → no counterattack" # BoI примечание boi_note = " *(→ also damages faction HP directly)*" if is_boi else "" hp_note = f"HP {target_asset.get('hp', '?')}/{target_asset.get('hp_max', '?')}" lines.append( f" - `{own_asset['name']}` → **{ta_name}** [{tf_name}, {hp_note}]{boi_note}\n" f" Roll: {roll_str} | dmg: {atk_dmg}{ctr_str}" ) return lines def _build_hide_asset_options(faction_fm: dict, all_factions: list[dict], location_wikilink: str) -> list[str]: """ Формирует список активов доступных для скрытия (Hide Asset). Условия WWN: - Cunning >= 3 - 2 Treasure за каждый скрываемый актив - Нельзя скрывать в локации с вражеским BoI """ if faction_fm.get("cunning", 0) < 3: return [] loc_norm = normalize_wikilink(location_wikilink) treasure = faction_fm.get("treasure", 0) # Есть ли вражеский BoI в локации? hostile_boi_in_loc = any( normalize_wikilink(a.get("location", "")) == loc_norm and a.get("is_base_of_influence") for tf in all_factions for a in tf.get("frontmatter", {}).get("assets", []) if tf.get("name") != faction_fm.get("name") ) if hostile_boi_in_loc: return [] lines = [] for a in faction_fm.get("assets", []): if a.get("is_base_of_influence"): continue if a.get("stealthed"): continue if normalize_wikilink(a.get("location", "")) != loc_norm: continue if treasure < 2: lines.append(f" - `{a['name']}` — ⚠️ недостаточно Treasure (нужно 2, есть {treasure})") else: lines.append(f" - `{a['name']}` — стоимость: 2 Treasure") return lines def _build_move_options(faction_fm: dict, location_wikilink: str) -> list[str]: """ Формирует список активов доступных для перемещения (Move Asset). Показывает текущую локацию каждого актива. Subtle/Stealthed активы могут двигаться без ограничений. """ lines = [] for a in faction_fm.get("assets", []): if a.get("is_base_of_influence"): continue name = a["name"] loc = a.get("location", "?") flags = [] if a.get("stealthed"): flags.append("Stealthed") if a.get("subtle"): flags.append("Subtle") flag_str = f" [{', '.join(flags)}]" if flags else "" lines.append(f" - `{name}`{flag_str} currently @ {loc}") return lines def _build_repair_options(faction_fm: dict) -> list[str]: """ Формирует список повреждённых активов доступных для ремонта (Repair Asset). """ treasure = faction_fm.get("treasure", 0) lines = [] for a in faction_fm.get("assets", []): hp = a.get("hp", 0) hp_max = a.get("hp_max", 0) if hp >= hp_max or hp_max == 0: continue attr = a.get("attribute", "Force") repair_info = calc_repair_heal(faction_fm, attr, 1) cost = repair_info["cost"] heal = repair_info["heal"] can = "✓" if treasure >= cost else f"⚠️ нужно {cost} Treasure, есть {treasure}" lines.append( f" - `{a['name']}` [HP {hp}/{hp_max}] " f"— heal {heal} HP за {cost} Treasure {can}" ) # Ремонт самой фракции faction_hp = faction_fm.get("hp", 0) faction_hp_max = faction_fm.get("hp_max", 1) if faction_hp < faction_hp_max: fr = calc_faction_repair_heal(faction_fm) can = "✓" if treasure >= fr["cost"] else f"⚠️ нужно {fr['cost']} Treasure" lines.append( f" - **Faction HP** [{faction_hp}/{faction_hp_max}] " f"— heal {fr['heal']} HP за {fr['cost']} Treasure {can}" ) return lines def _build_create_asset_options(faction_fm: dict, location_wikilink: str) -> list[str]: """ Формирует список активов доступных для покупки (Create Asset). """ available = get_available_assets(faction_fm, location_wikilink) if not available: return [" - *(нет доступных активов для покупки)*"] lines = [] for a in available: magic_note = f", Magic {a['magic_required']}" if a["magic_required"] != "None" else "" atk = f" | {a['attack_attribute'][0]} vs {a['attack_vs'][0]} / {a['attack_damage']}" \ if a.get("attack_attribute") else "" ctr = f" | ctr {a['counterattack']}" if a.get("counterattack") else "" lines.append( f" - `{a['name']}` [{a['attribute'][0]}{a['tier_required']}{magic_note}] " f"cost {a['cost']} Treasure, HP {a['hp_max']}{atk}{ctr}" ) return lines def _build_sell_options(faction_fm: dict) -> list[str]: """ Формирует список активов доступных для продажи (Sell Asset). По WWN: получить floor(cost/2) Treasure если актив не повреждён, иначе 0 Treasure. """ lines = [] for a in faction_fm.get("assets", []): if a.get("is_base_of_influence"): continue asset_def = get_asset(a["name"]) if not asset_def: continue hp = a.get("hp", 0) hp_max = a.get("hp_max", 1) if hp < hp_max: gain = 0 note = " (повреждён — 0 Treasure)" else: gain = math.floor(asset_def["cost"] / 2) note = "" lines.append(f" - `{a['name']}` → +{gain} Treasure{note}") return lines def _format_faction_section( idx: int, faction: dict, initiative: int, income_gained: int, upkeep_info: dict, all_factions: list[dict], location_wikilink: str, ) -> str: """ Форматирует полную секцию одной фракции для вывода в propose-промпт. Включает: статус, доход, апкип, активы в локации, все доступные действия с числами. """ fm = faction.get("frontmatter", {}) name = faction.get("name", "Unknown") state_label = _get_faction_state_label(fm) # Статус-строка status_str = format_faction_status(fm, name) # Нарративные теги для LLM dw_cat = fm.get("dw_category", "") impulse = fm.get("impulse", "") narrative_str = "" if dw_cat or impulse: narrative_str = f"\n**Нарратив:** dw_category={dw_cat} | impulse=*\"{impulse}\"*" # Faction tags WWN tags = fm.get("faction_tags", []) tags_str = f"\n**WWN Tags:** {', '.join(tags)}" if tags else "" # Цель фракции goal = fm.get("current_goal", "нет") xp = fm.get("xp", 0) upgrades = get_available_upgrades(fm) upgrade_str = "" if upgrades: u = upgrades[0] upgrade_str = ( f"\n**XP:** {xp} | Можно повысить: " f"{u['attribute']} {u['current_value']}→{u['next_value']} за {u['xp_cost']} XP" + (" ✓" if u["can_afford"] else " (не хватает XP)") ) # Активы в локации loc_norm = normalize_wikilink(location_wikilink) assets_here = [ a for a in fm.get("assets", []) if normalize_wikilink(a.get("location", "")) == loc_norm ] assets_lines = [] for a in assets_here: assets_lines.append(format_asset_line(a, is_base=a.get("is_base_of_influence", False))) assets_str = "\n".join(assets_lines) if assets_lines else " *(нет активов в этой локации)*" # Доход и апкип income_str = f"+{income_gained} Treasure (было {fm.get('treasure', 0) - income_gained} → стало {fm.get('treasure', 0)})" upkeep_str = "" if upkeep_info["total_upkeep"] > 0: upkeep_str = ( f"\n⚠️ **Апкип:** {upkeep_info['total_upkeep']} Treasure/ход | " f"лишние активы: {', '.join(upkeep_info['excess_assets']) or 'нет'}" ) # Доступные действия attack_opts = _build_attack_options(fm, all_factions, location_wikilink) hide_opts = _build_hide_asset_options(fm, all_factions, location_wikilink) move_opts = _build_move_options(fm, location_wikilink) repair_opts = _build_repair_options(fm) create_opts = _build_create_asset_options(fm, location_wikilink) sell_opts = _build_sell_options(fm) # Expand Influence treasure = fm.get("treasure", 0) expand_str = ( f" - Создать BoI в новой локации: 1 Treasure/HP нового BoI " f"(текущий Treasure: {treasure}, максимальный BoI HP: {treasure})\n" f" Потребует Cunning vs Cunning vs всех фракций с BoI в целевой локации" ) def _section(title: str, lines: list[str]) -> str: if not lines: return f"\n**[{title}]** *(недоступно)*" return f"\n**[{title}]**\n" + "\n".join(lines) return ( f"### {idx}. {name} | Initiative: {initiative} | {state_label.upper()}\n" f"**Статы:** {status_str}" f"{tags_str}" f"{narrative_str}" f"\n**Цель:** {goal}{upgrade_str}" f"\n**Доход этого хода:** {income_str}{upkeep_str}" f"\n\n**Активы в локации:**\n{assets_str}" f"\n\n#### Доступные действия:" + _section("ATTACK", attack_opts) + _section("CREATE ASSET", create_opts) + _section("HIDE ASSET", hide_opts) + _section("MOVE ASSET", move_opts) + _section("REPAIR ASSET", repair_opts) + f"\n**[EXPAND INFLUENCE]**\n{expand_str}" + _section("SELL ASSET", sell_opts) ) # --------------------------------------------------------------------------- # Главная функция # --------------------------------------------------------------------------- def propose_faction_turn( debug_description: str, requests: list[FactionTurnProposalRequest], ) -> str: """ Основной метод, который вызывает тул с фронта. Принимает список запросов на генерацию хода фракций. Для каждого запроса: - загружает все фракции из Obsidian vault через WebSocket-прокси; - фильтрует активные фракции в указанной локации; - бросает инициативу 1d8 для каждой фракции; - начисляет доход по формуле WWN: ceil(W/2 + (F+C)/4); - проверяет апкип и лимиты активов; - для каждой фракции строит полный список механически допустимых действий; - загружает последние события для контекста LLM; - формирует Markdown-промпт для AI-агента. Возвращает Markdown-строку с инструкцией для LLM. """ if not requests: requests = [{}] sections: list[str] = [] for i, raw_req in enumerate(requests, start=1): req = _merge_with_defaults(raw_req) location_wikilink = req["location_wikilink"] factions_folder = req["factions_folder"] events_folder = req["events_folder"] game_date = req["game_date"] max_events = req["max_events"] # ── Загрузка данных ────────────────────────────────────────────── try: all_factions = get_all_factions(factions_folder) except RuntimeError as e: sections.append(f"## ❌ Ошибка загрузки фракций\n{e}") continue active_in_loc = [ f for f in get_factions_in_location(all_factions, location_wikilink) if f.get("frontmatter", {}).get("state") != "defeated" ] if not active_in_loc: sections.append( f"## Ход фракций: {location_wikilink} | {game_date}\n\n" f"Активных фракций в локации не найдено." ) continue # ── Последние события ──────────────────────────────────────────── # Собираем все tags_listen активных фракций all_listen_tags: list[str] = [] for f in active_in_loc: all_listen_tags.extend(f.get("frontmatter", {}).get("tags_listen", [])) all_listen_tags = list(set(all_listen_tags)) try: recent_events = get_recent_events(events_folder, all_listen_tags, max_events) except RuntimeError: recent_events = [] # ── Инициатива и порядок ───────────────────────────────────────── factions_with_init = [] for faction in active_in_loc: init = _roll_initiative() factions_with_init.append((init, faction)) factions_with_init.sort(key=lambda x: x[0], reverse=True) # ── Начисление дохода и апкипа ─────────────────────────────────── # NOTE: Изменения Treasure применяются к копии frontmatter для отображения. # Реальная запись в файл происходит в resolve_faction_turn. for init, faction in factions_with_init: fm = faction.get("frontmatter", {}) income = calc_income( fm.get("wealth", 0), fm.get("force", 0), fm.get("cunning", 0), ) fm["treasure"] = fm.get("treasure", 0) + income # Апкип upkeep = calc_asset_upkeep(fm) fm["treasure"] = max(0, fm["treasure"] - upkeep["total_upkeep"]) faction["_income"] = income faction["_upkeep"] = upkeep # ── Сборка вывода ──────────────────────────────────────────────── header = ( f"# Ход фракций: {location_wikilink} | {game_date}\n\n" ) # Последние события events_block = "## Последние события (контекст для LLM)\n" if recent_events: for ev in recent_events: ev_name = ev.get("name", "?") ev_actor = ev.get("frontmatter", {}).get("actor", "?") ev_action = ev.get("frontmatter", {}).get("action_type", "?") events_block += f"- [[{ev_name}]]: {ev_actor} — {ev_action}\n" else: events_block += "- *(событий не найдено)*\n" # Секции фракций faction_sections = [] for idx, (init, faction) in enumerate(factions_with_init, start=1): fm = faction.get("frontmatter", {}) section = _format_faction_section( idx=idx, faction=faction, initiative=init, income_gained=faction.get("_income", 0), upkeep_info=faction.get("_upkeep", {"total_upkeep": 0, "excess_assets": []}), all_factions=all_factions, location_wikilink=location_wikilink, ) faction_sections.append(section) # Инструкция для LLM llm_instruction = ( "\n---\n\n" "## ИНСТРУКЦИЯ ДЛЯ LLM\n\n" "Ты — опытный Мастер Игры TTRPG, ведущий живой мир.\n" "Для каждой фракции со статусом ACTIVE, DAMAGED или CRITICAL:\n\n" "1. Выбери **одно действие** из предложенного списка. " "Обоснуй через impulse и последние события (1–2 предложения).\n" "2. Если выбрано ATTACK — укажи точную пару (актив → цель) из списка.\n" "3. Если выбрано CREATE ASSET — укажи точное название актива из списка.\n" "4. Если выбрано EXPAND INFLUENCE — укажи целевую локацию и HP нового BoI.\n" "5. Если выбрано HIDE ASSET — укажи какой актив скрывается.\n" "6. Если выбрано MOVE ASSET — укажи актив и destination-локацию.\n" "7. В конце добавь блок **'ДЕЙСТВИЯ ПО УМОЛЧАНИЮ'** — самый логичный выбор " "одной строкой для каждой фракции.\n\n" "**НЕ МЕНЯЙ** механические данные (броски, урон, стоимости).\n" "**НЕ ВЫДУМЫВАЙ** действия которых нет в списке.\n" "После выбора ГМ вызовет `resolve_faction_turn` с конкретными параметрами." ) full_section = ( header + events_block + "\n---\n\n" + "\n\n---\n\n".join(faction_sections) + llm_instruction ) sections.append(full_section) return "\n\n---\n\n".join(sections) # --------------------------------------------------------------------------- # Точка входа для тестирования # --------------------------------------------------------------------------- if __name__ == "__main__": result = propose_faction_turn( "тест propose_faction_turn", [ { "location_wikilink": "[[Waterdeep]]", "factions_folder": "Фракции", "events_folder": "События", "game_date": "15 Mirtul 1492 DR", "max_events": 5, }, { "location_wikilink": "[[Neverwinter]]", # остальные поля → дефолты }, ], ) print(result)