dnd5-scripts/faction_turns/propose_faction_turn.py
2026-07-08 00:11:52 +03:00

887 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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 (
WWN_MILES_PER_TURN,
get_travel_distance,
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.
Допустимы: 120.
По умолчанию: 5.
"""
location_wikilink: str # wikilink локации, обязательно: "[[Уотердип]]"
factions_folder: str # default "Фракции"
events_folder: str # default "События"
game_date: str # default: today
max_events: int # 120, 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.
Возвращает число 18.
"""
return random.randint(1, 8)
def _get_faction_state_label(faction_fm: dict) -> str:
"""
Возвращает читаемый статус фракции с учётом HP.
По WWN нет формальных state-меток, но добавляем для LLM:
HP 100% → active
HP 5099% → damaged
HP 149% → 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,
all_factions: list[dict],
location_wikilink: str
) -> list[str]:
"""Формирует список активов с вариантами перемещения."""
lines = []
faction_locations = _get_faction_locations(faction_fm)
hostile_locations = _get_hostile_locations(all_factions, faction_fm)
for asset in faction_fm.get("assets", []):
if asset.get("is_base_of_influence"):
continue
name = asset["name"]
current_loc = asset.get("location", "?")
flags = []
if asset.get("stealthed"):
flags.append("Stealthed")
if asset.get("subtle"):
flags.append("Subtle")
destinations = _get_possible_destinations(
current_loc,
faction_locations,
hostile_locations,
bool(flags)
)
flag_str = f" [{', '.join(flags)}]" if flags else ""
lines.append(
f" - `{name}`{flag_str} @ {current_loc}\n"
f" → возможные направления: {', '.join(destinations) or 'нет вариантов'}"
)
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)
loc = a.get("location", "?")
if hp < hp_max:
gain = 0
note = " (повреждён — 0 Treasure)"
else:
gain = math.floor(asset_def["cost"] / 2)
note = ""
lines.append(f" - `{a['name']}` @ {loc} → +{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, all_factions, 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 в новой локации (`{location_wikilink}`): 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"**Файл:** `{name}.md`\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 _get_faction_locations(faction_fm: dict) -> set[str]:
"""Собирает все локации фракции (из locations и активов)."""
locations = set()
for loc in faction_fm.get("locations", []):
locations.add(normalize_wikilink(loc))
for asset in faction_fm.get("assets", []):
if loc := asset.get("location"):
locations.add(normalize_wikilink(loc))
return locations
def _get_possible_destinations(
current_loc: str,
faction_locations: set[str],
hostile_locations: set[str],
is_subtle_or_stealthed: bool
) -> list[str]:
"""Генерирует список доступных локаций для перемещения."""
destinations = []
for loc in faction_locations:
if loc == normalize_wikilink(current_loc):
continue
distance = get_travel_distance(current_loc, loc)
if distance is None or distance > WWN_MILES_PER_TURN:
continue
if loc in hostile_locations and not is_subtle_or_stealthed:
destinations.append(f"{loc} ☠️ (враждебная)")
else:
destinations.append(loc)
return destinations
def _get_hostile_locations(
all_factions: list[dict],
current_faction_fm: dict
) -> set[str]:
"""Находит все враждебные локации (где есть BoI врагов)."""
hostile_locs = set()
current_name = current_faction_fm.get("name", "").strip()
for faction in all_factions:
other_fm = faction.get("frontmatter", {})
other_name = other_fm.get("name", "").strip()
if not other_name or other_name == current_name:
continue
if _is_hostile_faction(current_faction_fm, other_fm):
for asset in other_fm.get("assets", []):
if asset.get("is_base_of_influence"):
if loc := asset.get("location"):
hostile_locs.add(normalize_wikilink(loc))
return hostile_locs
def _get_faction_relations(faction_fm: dict) -> tuple[set[str], set[str]]:
"""Возвращает (множество врагов, множество союзников)."""
return (
set(str(x).strip() for x in faction_fm.get("enemies", []) if x),
set(str(x).strip() for x in faction_fm.get("allies", []) if x)
)
def _is_hostile_faction(
current_fm: dict,
target_fm: dict,
default_hostile: bool = False
) -> bool:
"""
Проверяет, является ли target_fm враждебной для current_fm.
default_hostile - считать ли фракции враждебными по умолчанию.
"""
enemies, allies = _get_faction_relations(current_fm)
target_name = target_fm.get("name", "").strip()
if target_name in enemies:
return True
if target_name in allies:
return False
return default_hostile
# ---------------------------------------------------------------------------
# Главная функция
# ---------------------------------------------------------------------------
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 и последние события (12 предложения).\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)