dnd5-scripts/generate_encounter/gen_encounter.py

533 lines
27 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 gen_npc import generate_npc as get_npc
# Множество планов существования (не прайм-миры).
# Если world попадает сюда — монстры берутся из planar_monsters_data
# (через ключ world), а биом environment игнорируется.
PLANAR_WORLDS: set[str] = {
"astral_sea", "plane_of_air", "plane_of_earth", "plane_of_fire", "plane_of_water",
"feywild", "shadowfell",
"lower_planes_le", "lower_planes_ne", "lower_planes_ce",
"upper_planes_lg", "upper_planes_ng", "upper_planes_cg",
"planes_of_chaos", "plane_of_law",
}
# ──────────────────────────────────────────────────────────────────────────────
# TypedDict
# ──────────────────────────────────────────────────────────────────────────────
class EncounterRequest(TypedDict, total=False):
"""
Параметры одного запроса случайной встречи.
Поля:
environment: Биом.
Допустимы: arctic, coastal, desert, forest,
grassland, hill, mountain, swamp, underdark,
underwater, urban.
По умолчанию: forest.
world: Космологический уровень сцены (англ.). Прайм-мир
("Toril", "Krynn", "Oerth") ЛИБО план существования ("feywild",
"shadowfell", "lower_planes_le/ne/ce",
"upper_planes_lg/ng/cg", "planes_of_chaos",
"plane_of_law", "astral_sea", "plane_of_air",
"plane_of_earth", "plane_of_fire", "plane_of_water").
Пусто → прайм-мир, монстры по биому environment.
region: Регион ВНУТРИ прайм-мира (обычное англ. название,
код нормализует через symspellpy).
Определение СТРОГО ПО ПРИОРИТЕТУ:
0. Никаких артиклей нигде применять нельзя, пример, nelanther_isles, а не the_nelanther_isles
1. Если world — план, region опускается.
2. Подземелье/пещера/канализация → "Underdark".
3. Крупный город → название города
(Waterdeep, Baldur's Gate, Neverwinter,
Suzail, Athkatla, Calimport, Menzoberranzan).
4. Дикая местность → историческая область по
ближайшему городу/хребту/лесу
(Western Heartlands, Silver Marches, Cormyr,
Amn, Tethyr, Thay, Chult и т.д.).
5. Море/побережье → ближайшая страна или
уникальная акватория (Sea of Fallen Stars).
6. Не определить → пусто (глобальные шансы).
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
world: str
region: 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,
world: Optional[str] = None,
region: Optional[str] = None,
) -> list[dict]:
"""
По схеме хука вызывает get_creature() или get_npc()
и возвращает список субъектов с ролями.
"""
hook_data = HOOKS.get(enc_type, {}).get(hook_name, {})
slots = hook_data.get("subject_sources", [])
subjects = []
# Определяем ключ для таблиц монстров:
# если world — план, монстры берутся из planar-таблиц по ключу world,
# иначе (прайм-мир) — по биому environment.
is_planar = bool(world) and world.lower().strip() in PLANAR_WORLDS
creature_env = world.lower().strip() if is_planar else env
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(creature_env, level)
elif source == "npc":
# Приоритет — географический контекст (world/region).
# Если он задан, get_npc сам подберёт расы через REGIONAL_OVERWRITES.
# Иначе — fallback на список рас по биому.
npc_constraint = {
"count": count,
"level": level,
"commoner_chance": 0.85,
}
if world:
npc_constraint["world"] = world
if region:
npc_constraint["region"] = region
else:
# TODO: не очевидноЮ тут бы подумал, но, кажется, тут есть смысл пробрасывать только если у этого тула есть такой же параметр, чтобы можно было создать столкновения только с человеческими бандитами
npc_constraint["allowed_races"] = get_npc_races(env)
result_text = get_npc(
debug_description="encounter npc",
constraints=[npc_constraint],
)
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))
# Географический / планарный контекст
world = req.get("world", None)
region = req.get("region", None)
# Считываем явно заданный тип или None
req_type = req.get("encounter_type")
# ── Тип и хук ────────────────────────────────────────────────────────
# Для выбора весов типа встречи: если world — план, используем его,
# иначе — биом прайм-мира.
type_key = world.lower().strip() if (world and world.lower().strip() in PLANAR_WORLDS) else env
enc_type = _pick_encounter_type(type_key, 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, world, region)
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", {}))
# ── Сборка секции ─────────────────────────────────────────────────────
loc_line = ""
if world:
loc_line += f"- **Мир/План:** {world}\n"
if region:
loc_line += f"- **Регион:** {region}\n"
section = (
f"### Встреча №{i}\n"
f"- **Биом:** {env.capitalize()}\n"
f"{loc_line}"
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))