787 lines
32 KiB
Python
787 lines
32 KiB
Python
"""
|
||
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,
|
||
write_faction,
|
||
)
|
||
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)
|
||
|
||
# ----- ИСПРАВЛЕННАЯ ВСПОМОГАТЕЛЬНАЯ ФУНКЦИЯ -----
|
||
# Возвращает пустую строку, если список опций пуст
|
||
def _section(title: str, lines: list[str]) -> str:
|
||
if not lines:
|
||
return "" # <-- теперь не выводится заголовок "недоступно"
|
||
return f"\n**[{title}]**\n" + "\n".join(lines)
|
||
|
||
# ----- Блок Expand Influence (теперь только если доступен) -----
|
||
treasure = fm.get("treasure", 0)
|
||
has_regular_asset_in_loc = any(
|
||
not a.get("is_base_of_influence") and normalize_wikilink(a.get("location", "")) == loc_norm
|
||
for a in fm.get("assets", [])
|
||
)
|
||
expand_section = ""
|
||
if has_regular_asset_in_loc:
|
||
expand_str = (
|
||
f" - Создать BoI в новой локации: 1 Treasure/HP нового BoI "
|
||
f"(текущий Treasure: {treasure}, максимальный BoI HP: {treasure})\n"
|
||
f" Потребует Cunning vs Cunning vs всех фракций с любым активом в целевой локации"
|
||
)
|
||
expand_section = f"\n**[EXPAND INFLUENCE]**\n{expand_str}"
|
||
|
||
# Собираем все секции, кроме EXPAND INFLUENCE (он уже отдельно)
|
||
actions_sections = (
|
||
_section("ATTACK", attack_opts) +
|
||
_section("CREATE ASSET", create_opts) +
|
||
_section("HIDE ASSET", hide_opts) +
|
||
_section("MOVE ASSET", move_opts) +
|
||
_section("REPAIR ASSET", repair_opts) +
|
||
expand_section +
|
||
_section("SELL ASSET", sell_opts)
|
||
)
|
||
|
||
if not actions_sections.strip():
|
||
actions_sections = "\n*(нет доступных действий — фракция пропускает ход)*"
|
||
|
||
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#### Доступные действия:"
|
||
f"{actions_sections}"
|
||
)
|
||
|
||
|
||
def _ensure_startup_asset(
|
||
faction: dict,
|
||
factions_folder: str,
|
||
default_location: str = "[[Уотердип]]"
|
||
) -> bool:
|
||
"""
|
||
Проверяет, есть ли у фракции активы (включая BoI). Если нет, и фракция новая
|
||
(нет last_turn_date и last_action), то добавляет самый дешёвый актив tier 1
|
||
в первую локацию из списка locations (или default_location) БЕСПЛАТНО.
|
||
"""
|
||
fm = faction.get("frontmatter", {})
|
||
# Пропускаем побеждённые или мёртвые
|
||
if fm.get("state") == "defeated" or fm.get("hp", 0) <= 0:
|
||
return False
|
||
|
||
# Если есть хоть один актив (включая BoI) — ничего не делаем
|
||
if fm.get("assets"):
|
||
return False
|
||
|
||
# Если фракция уже действовала ранее — не даём халявный актив
|
||
if fm.get("last_turn_date") or fm.get("last_action"):
|
||
return False
|
||
|
||
# Выбираем локацию
|
||
locations = fm.get("locations", [])
|
||
if not locations:
|
||
loc = default_location
|
||
fm.setdefault("locations", []).append(default_location)
|
||
else:
|
||
loc = locations[0]
|
||
|
||
# Определяем наивысший атрибут (приоритет: Cunning > Force > Wealth)
|
||
attrs = {
|
||
"Cunning": fm.get("cunning", 0),
|
||
"Force": fm.get("force", 0),
|
||
"Wealth": fm.get("wealth", 0),
|
||
}
|
||
priority = {"Cunning": 0, "Force": 1, "Wealth": 2}
|
||
sorted_attrs = sorted(attrs.items(), key=lambda x: (-x[1], priority[x[0]]))
|
||
best_attr = sorted_attrs[0][0]
|
||
|
||
from wwn_assets_data import get_assets_by_attribute, ALL_ASSETS
|
||
candidates = [a for a in get_assets_by_attribute(best_attr) if a["tier_required"] == 1]
|
||
if not candidates:
|
||
candidates = [a for a in ALL_ASSETS if a["tier_required"] == 1]
|
||
if not candidates:
|
||
return False
|
||
|
||
best_asset = min(candidates, key=lambda a: a["cost"])
|
||
|
||
# Создаём актив бесплатно (не тратим Treasure)
|
||
new_asset = {
|
||
"name": best_asset["name"],
|
||
"is_base_of_influence": False,
|
||
"attribute": best_asset["attribute"],
|
||
"tier_required": best_asset["tier_required"],
|
||
"cost": best_asset["cost"],
|
||
"hp": best_asset["hp_max"],
|
||
"hp_max": best_asset["hp_max"],
|
||
"location": loc,
|
||
"stealthed": False, # по умолчанию активы не скрыты, даже если имеют Subtle
|
||
"subtle": False,
|
||
}
|
||
fm.setdefault("assets", []).append(new_asset)
|
||
# Treasure не изменяется
|
||
|
||
# Сохраняем изменения в файл
|
||
file_path = faction.get("path")
|
||
if file_path:
|
||
body = faction.get("body", "")
|
||
success = write_faction(file_path, fm, body)
|
||
if success:
|
||
faction["frontmatter"] = fm
|
||
return True
|
||
return False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Главная функция
|
||
# ---------------------------------------------------------------------------
|
||
|
||
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
|
||
|
||
# Добавляем бесплатный стартовый актив, если надо
|
||
_ensure_startup_asset(faction, factions_folder, location_wikilink)
|
||
|
||
# ── Сборка вывода ────────────────────────────────────────────────
|
||
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) |