diff --git a/generate_encounter/generate_encounter.py b/generate_encounter/generate_encounter.py index 1741f68..011a280 100644 --- a/generate_encounter/generate_encounter.py +++ b/generate_encounter/generate_encounter.py @@ -59,6 +59,17 @@ from get_npc_races import get_npc_races 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 # ────────────────────────────────────────────────────────────────────────────── @@ -72,12 +83,34 @@ class EncounterRequest(TypedDict, total=False): environment: Биом. Допустимы: arctic, coastal, desert, forest, grassland, hill, mountain, swamp, underdark, - underwater, urban, - 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. + 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. @@ -119,6 +152,8 @@ class EncounterRequest(TypedDict, total=False): По умолчанию: True. """ environment: str + world: str + region: str level: int encounter_type: Optional[str] party_surprise_mods: Dict[str, int] @@ -219,23 +254,28 @@ def _roll_count(notation: str) -> int: # Вспомогательные функции — сборка субъектов # ────────────────────────────────────────────────────────────────────────────── -def _build_subjects(hook_name: str, enc_type: str, env: str, level: int) -> list[dict]: +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() и возвращает список субъектов с ролями. - - Каждый элемент результата: - { - "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 = [] + # Определяем ключ для таблиц монстров: + # если 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: @@ -246,17 +286,27 @@ def _build_subjects(hook_name: str, enc_type: str, env: str, level: int) -> list role = slot.get("role", "primary") if source == "creatures": - result_text = get_creature(env, level) + result_text = get_creature(creature_env, level) elif source == "npc": - allowed_races = get_npc_races(env) + # Приоритет — географический контекст (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=[{ - "count": count, - "level": level, - "commoner_chance": 0.85, - "allowed_races": allowed_races, - }], + constraints=[npc_constraint], ) else: result_text = f"[Неизвестный источник: {source}]" @@ -390,18 +440,25 @@ def generate_encounter( 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") # ── Тип и хук ──────────────────────────────────────────────────────── - enc_type = _pick_encounter_type(env, req_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) + subjects = _build_subjects(hook_name, enc_type, env, level, world, region) subjects_text = _format_subjects(subjects, latent) # ── Surprise ────────────────────────────────────────────────────────── @@ -420,9 +477,16 @@ def generate_encounter( 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"