""" 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))