dnd5-scripts/generate_encounter/generate_encounter.py
2026-06-17 22:32:48 +03:00

466 lines
23 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.

"""
encounter_generator.py — генератор случайных встреч по биомам и уровням.
Интегрирует:
- Классификацию встреч (PvP / PvE / PvX / PvSelf) из hooks_data.py
- Хуки и схемы субъектов из hooks_data.py
- Существ из get_creature.py
- NPC из get_npc.py
- Механики Surprise и Reaction по правилам ACKS II
Экспортирует функцию generate_encounter(debug_description, requests).
requests — список объектов вида:
{
"environment": "forest",
"level": 5,
"encounter_type": "PvP", # Optional: PvP, PvE, PvX, PvSelf. По умолчанию случайно.
"party_surprise_mods": {"Высокое восприятие": 1},
"monster_surprise_mods": {"Скрытность варвара": -2},
"reaction_mods": {"Харизма жреца": 2},
"party_foreknowledge": False,
"party_los": True,
"monster_foreknowledge": False,
"monster_los": True,
}
Возвращает строку в формате Markdown.
"""
import sys
import os
from pathlib import Path
import random
from typing import Dict, Optional
from typing_extensions import TypedDict
# --- ИСПРАВЛЕНИЕ ПУТЕЙ ---
# 1. Получаем путь к папке scripts
current_file_path = Path(__file__).resolve()
scripts_path = current_file_path.parent.parent
# Добавляем пути в sys.path, чтобы Python видел все папки
if str(scripts_path) not in sys.path:
sys.path.insert(0, str(scripts_path))
# Добавляем папку с NPC в пути поиска напрямую
npc_folder = scripts_path / "generate_npc"
if str(npc_folder) not in sys.path:
sys.path.insert(0, str(npc_folder))
# --- ТЕПЕРЬ АБСОЛЮТНЫЕ ИМПОРТЫ (без точек) ---
from hooks_data import ENCOUNTER_TYPE_WEIGHTS, HOOK_WEIGHTS, HOOKS
from get_creature import get_creature
from get_npc_races import get_npc_races
# Импортируем функцию из соседней папки.
# Поскольку в sys.path есть npc_folder, можно импортировать напрямую из файла
from generate_npc import generate_npc as get_npc
# ──────────────────────────────────────────────────────────────────────────────
# TypedDict
# ──────────────────────────────────────────────────────────────────────────────
class EncounterRequest(TypedDict, total=False):
"""
Параметры одного запроса случайной встречи.
Поля:
environment: Биом.
Допустимы: arctic, coastal, desert, forest,
grassland, hill, mountain, swamp, underdark,
underwater, urban, nine_hells, astral_sea, feywild,
plane_of_air, plane_of_earth, plane_of_fire, plane_of_water.
По умолчанию: forest.
level: Средний уровень группы (120).
По умолчанию: 1.
encounter_type: Категория столкновения (определяет характер взаимодействия):
- PvP (Персонаж против Монстра/NPC): Бой, противостояние воль,
прямой конфликт или активное взаимодействие с существами.
- PvE (Персонаж против Среды): Преодоление стихии, ловушки,
природные аномалии, физические препятствия.
- PvX (Персонаж против Неизвестного): Тайны, находки, следы,
заброшенные логова, знамения или артефакты.
- PvSelf (Персонаж против Себя): Моральный выбор, искушение,
проверка веры, этическая дилемма или мольба о помощи.
КАТЕГОРИЧЕСКИ ОСТАВЛЯЙ ЭТО ПОЛЕ ПУСТЫМ (null/None), если пользователь
явно не выбрал тип. Не пытайся угадать его самостоятельно.
Если не указан — выбирается случайно по весам биома.
party_surprise_mods: Словарь модификаторов броска внезапности партии.
Пример: {"Бдительность": 1}.
По умолчанию: {}.
monster_surprise_mods: Словарь модификаторов броска внезапности монстров.
Пример: {"Скрытность эльфа": -2}.
По умолчанию: {}.
reaction_mods: Словарь модификаторов броска реакции.
Пример: {"Харизма партийного лица": 2, "Известный враг": -1}.
По умолчанию: {}.
party_foreknowledge: Есть ли у партии предзнание (Foreknowledge)?
По умолчанию: False.
party_los: Есть ли у партии прямая видимость (LOS) в начале?
По умолчанию: True.
monster_foreknowledge: сть ли у монстров предзнание?
По умолчанию: False.
monster_los: Есть ли у монстров прямая видимость в начале?
По умолчанию: True.
"""
environment: str
level: int
encounter_type: Optional[str]
party_surprise_mods: Dict[str, int]
monster_surprise_mods: Dict[str, int]
reaction_mods: Dict[str, int]
party_foreknowledge: Optional[bool]
party_los: Optional[bool]
monster_foreknowledge: Optional[bool]
monster_los: Optional[bool]
# ──────────────────────────────────────────────────────────────────────────────
# Вспомогательные функции — выбор типа и хука
# ──────────────────────────────────────────────────────────────────────────────
def _weighted_pick(weights: dict[str, int]) -> str:
"""
Выбирает ключ из словаря {name: weight} взвешенным случайным образом.
"""
total = sum(weights.values())
r = random.uniform(0, total)
cumulative = 0
for name, weight in weights.items():
cumulative += weight
if r <= cumulative:
return name
return list(weights.keys())[-1]
def _pick_encounter_type(env: str, requested_type: Optional[str] = None) -> str:
"""
Выбирает тип встречи (PvP/PvE/PvX/PvSelf).
Если requested_type передан и валиден, использует его.
Если нет — выбирает случайно по весам биома.
"""
# Список всех доступных типов из данных
valid_types = ["PvP", "PvE", "PvX", "PvSelf"]
# Если пользователь передал конкретный тип, проверяем его наличие
if requested_type and requested_type in valid_types:
return requested_type
weights = ENCOUNTER_TYPE_WEIGHTS.get(
env,
{"PvP": 25, "PvE": 25, "PvX": 25, "PvSelf": 25}
)
return _weighted_pick(weights)
def _pick_hook(enc_type: str) -> str:
"""Выбирает хук внутри типа встречи по универсальным весам."""
weights = HOOK_WEIGHTS.get(enc_type, {})
if not weights:
return "Unknown Hook"
return _weighted_pick(weights)
# ──────────────────────────────────────────────────────────────────────────────
# Вспомогательные функции — разбор dice-нотации
# ──────────────────────────────────────────────────────────────────────────────
def _roll_count(notation: str) -> int:
"""
Разбирает строку вида "1d4+1", "1d6", "1d3", "1" и возвращает число.
Поддерживает: NdM, NdM+K, NdM-K, просто N.
"""
notation = notation.strip().lower()
try:
# Просто число
if "d" not in notation:
return int(notation)
# Разбиваем по "d"
parts = notation.split("d")
num_dice = int(parts[0]) if parts[0] else 1
# Есть ли модификатор?
if "+" in parts[1]:
die_parts = parts[1].split("+")
die_size = int(die_parts[0])
modifier = int(die_parts[1])
elif "-" in parts[1]:
die_parts = parts[1].split("-")
die_size = int(die_parts[0])
modifier = -int(die_parts[1])
else:
die_size = int(parts[1])
modifier = 0
result = sum(random.randint(1, die_size) for _ in range(num_dice)) + modifier
return max(1, result) # минимум 1
except (ValueError, IndexError):
return 1 # fallback
# ──────────────────────────────────────────────────────────────────────────────
# Вспомогательные функции — сборка субъектов
# ──────────────────────────────────────────────────────────────────────────────
def _build_subjects(hook_name: str, enc_type: str, env: str, level: int) -> list[dict]:
"""
По схеме хука вызывает get_creature() или get_npc()
и возвращает список субъектов с ролями.
Каждый элемент результата:
{
"source": "creatures" | "npc",
"role": "primary" | "aggressor" | "defender" | "victim" | "bystander",
"count": int, # сколько существ/NPC
"result": str, # текст из таблицы или описание NPC
}
"""
hook_data = HOOKS.get(enc_type, {}).get(hook_name, {})
slots = hook_data.get("subject_sources", [])
subjects = []
for slot in slots:
# Опциональный слот — 50% шанс появления
if slot.get("optional") and random.random() < 0.5:
continue
count = _roll_count(slot.get("count", "1"))
source = slot.get("source", "creatures")
role = slot.get("role", "primary")
if source == "creatures":
result_text = get_creature(env, level)
elif source == "npc":
allowed_races = get_npc_races(env)
result_text = get_npc(
debug_description="encounter npc",
constraints=[{
"count": count,
"level": level,
"commoner_chance": 0.85,
"allowed_races": allowed_races,
}],
)
else:
result_text = f"[Неизвестный источник: {source}]"
subjects.append({
"source": source,
"role": role,
"count": count,
"result": result_text,
})
return subjects
def _format_subjects(subjects: list[dict], latent_threat: bool) -> str:
"""Форматирует список субъектов в читаемую Markdown-строку."""
if not subjects and not latent_threat:
return "- **Субъекты:** Отсутствуют (средовая угроза или объект)."
lines = []
for s in subjects:
role_label = {
"primary": "Основная группа",
"aggressor": "Агрессор",
"defender": "Защитник",
"victim": "Жертва",
"bystander": "Случайный свидетель",
}.get(s["role"], s["role"])
lines.append(
f"- **{role_label}** [{s['source']}]: {s['result']}"
)
if latent_threat:
lines.append(
"- ⚠️ **Скрытая угроза (Latent Threat):** существа могут появиться "
"при провале броска Surprise."
)
return "\n".join(lines)
# ──────────────────────────────────────────────────────────────────────────────
# Вспомогательные функции — Surprise и Reaction (без изменений логики)
# ──────────────────────────────────────────────────────────────────────────────
def _format_mods(mods: Dict[str, int]) -> str:
"""Форматирует словарь модификаторов в строку."""
if not mods:
return "Нет"
return ", ".join(f"{k} ({v:+d})" for k, v in mods.items())
def _determine_surprise(
mods: Dict[str, int],
foreknowledge: bool,
side_name: str,
) -> str:
"""Рассчитывает внезапность для одной стороны по правилам ACKS II."""
if foreknowledge:
return f"**{side_name}:** Не застигнуты врасплох (Было предзнание/Foreknowledge)."
mod_sum = sum(mods.values()) if mods else 0
roll = random.randint(1, 6)
total = roll + mod_sum
status = ("ВНИМАТЕЛЬНЫ (Attentive)"
if total >= 3 else "ЗАСТИГНУТЫ ВРАСПЛОХ (Surprised)")
return (f"**{side_name}:** {status} "
f"(1d6: {roll} + мод: {mod_sum} = {total}) "
f"| Модификаторы: {_format_mods(mods)}")
def _determine_reaction(mods: Dict[str, int]) -> str:
"""Рассчитывает первичную реакцию броском 2d6 по правилам ACKS II."""
mod_sum = sum(mods.values()) if mods else 0
d1, d2 = random.randint(1, 6), random.randint(1, 6)
roll = d1 + d2
total = roll + mod_sum
# ACKS Reaction limits logic for natural rolls
if roll == 2 and total > 5:
total = 5 # Немодифицированная двойка не может быть лучше "Unfriendly"
elif roll == 12 and total < 9:
total = 9 # Немодифицированные 12 не могут быть хуже "Indifferent"
if total <= 2:
result = "Враждебные (Hostile) — Немедленно атакуют!"
elif total <= 5:
result = "Недружелюбные (Unfriendly) — Не доверяют и могут атаковать при шансе на победу."
elif total <= 8:
result = "Нейтральные (Neutral) — Не уверены; реакция зависит от действий партии."
elif total <= 11:
result = "Индифферентные (Indifferent) — Проигнорируют, если их не тревожить."
else:
result = "Дружелюбные (Friendly) — Заинтересованы в сотрудничестве или разговоре."
return (f"**Итоговая реакция:** {result}\n"
f"- Бросок: 2d6 ({d1}+{d2}={roll}), "
f"модификаторы: {mod_sum} ({_format_mods(mods)}), "
f"итоговое значение: {total}")
# ──────────────────────────────────────────────────────────────────────────────
# Публичная функция
# ──────────────────────────────────────────────────────────────────────────────
def generate_encounter(
debug_description: str,
requests: list[EncounterRequest],
) -> str:
"""
Основной метод, который вызывает тул.
Для каждого запроса:
1. Выбирает тип встречи по весам биома.
2. Выбирает хук внутри типа.
3. По схеме хука вызывает get_creature() и/или get_npc().
4. Разрешает Surprise и Reaction по ACKS II.
5. Формирует Markdown-секцию.
Возвращает секции, объединённые через '---'.
"""
if not requests:
requests = [{}]
results = []
for i, req in enumerate(requests, 1):
env = req.get("environment", "forest").lower().strip()
level = int(req.get("level", 1))
# Считываем явно заданный тип или None
req_type = req.get("encounter_type")
# ── Тип и хук ────────────────────────────────────────────────────────
enc_type = _pick_encounter_type(env, req_type)
hook_name = _pick_hook(enc_type)
hook_meta = HOOKS.get(enc_type, {}).get(hook_name, {})
hook_desc = hook_meta.get("description", "")
latent = hook_meta.get("latent_threat", False)
# ── Субъекты ──────────────────────────────────────────────────────────
subjects = _build_subjects(hook_name, enc_type, env, level)
subjects_text = _format_subjects(subjects, latent)
# ── Surprise ──────────────────────────────────────────────────────────
p_surprise = _determine_surprise(
req.get("party_surprise_mods", {}),
req.get("party_foreknowledge", False),
"Партия",
)
m_surprise = _determine_surprise(
req.get("monster_surprise_mods", {}),
req.get("monster_foreknowledge", False),
"Монстры/NPC",
)
# ── Reaction ──────────────────────────────────────────────────────────
reaction_text = _determine_reaction(req.get("reaction_mods", {}))
# ── Сборка секции ─────────────────────────────────────────────────────
section = (
f"### Встреча №{i}\n"
f"- **Биом:** {env.capitalize()}\n"
f"- **Уровень группы:** {level}\n"
f"- **Тип встречи:** {enc_type}\n"
f"- **Хук:** {hook_name} — *{hook_desc}*\n\n"
f"#### Субъекты\n"
f"{subjects_text}\n\n"
f"#### Проверка Внезапности (Surprise)\n"
f"- {p_surprise}\n"
f"- {m_surprise}\n\n"
f"#### Первичная Реакция (Reaction)\n"
f"{reaction_text}"
)
results.append(section)
return "\n\n---\n\n".join(results)
# ──────────────────────────────────────────────────────────────────────────────
# Быстрый тест
# ──────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
test_reqs = [
{
"environment": "forest",
"level": 3,
"party_surprise_mods": {"Осторожность": 1},
"monster_surprise_mods": {"Громоздкие шаги": -1},
"reaction_mods": {"Харизма лица": 2, "Грязный вид": -1},
"party_foreknowledge": False,
"party_los": True,
"monster_foreknowledge": False,
"monster_los": True,
},
{
"environment": "urban",
"level": 5,
},
{
"environment": "nine_hells",
"level": 12,
},
]
print(generate_encounter("Тест", test_reqs))