466 lines
23 KiB
Python
466 lines
23 KiB
Python
"""
|
||
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: Средний уровень группы (1–20).
|
||
По умолчанию: 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))
|