""" 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 # Множество планов существования (не прайм-миры). # Если 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: Средний уровень группы (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 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))