""" henchmen.py — головной модуль генератора D&D NPC. Экспортирует функцию generate_henchmen(constraints), которую вызывает тул с фронта. constraints — список объектов вида: { "allowed_races": ["Human", "Elf", ...], # пустой = все "min_age": "Youth", # ключ из AGES (включительно) "max_age": "Old", # ключ из AGES (включительно) "allowed_classes": ["Fighter", "Rogue"], # пустой = все (включая Commoner) "allowed_languages":["Elvish", "Dwarvish"], # доп. языки (кроме Common); пустой = все "commoner_chance": 0.90, # вероятность Commoner (0.0–1.0), default 0.90 # необязательные поля для управления количеством "count": 5, # сколько NPC сгенерировать (default 1) "level": 3, # уровень NPC (default 1) } Возвращает строку в формате Markdown. """ import random import math from typing import Any, Optional from typing_extensions import TypedDict import norminv import languages as lang from ages import AGES, get_age_and_modify_abilities from abilities import get_ability_scores from classes import ( CLASSES, CLASSES_CHANCE, COMMONER, DEFAULT_COMMONER_CHANCE, get_random_class, ) from races import RACES, select_random_race_and_subrace, get_name, get_languages from backgrounds import get_background, get_class_skills_with_background from psycho_types import get_psychosocial_type_and_archetype from bio_tables import generate_bio # ─── symspellpy: индекс регионов ────────────────────────────────────────────── from symspellpy import SymSpell, Verbosity from regions import ( REGIONAL_OVERWRITES, PLANAR_OVERWRITES, WORLD_INDEX, REGION_INDEX, _clean, ) _SYM = SymSpell(max_dictionary_edit_distance=2, prefix_length=7) for _cleaned_alias in {a for (_scope, a) in REGION_INDEX}: _SYM.create_dictionary_entry(_cleaned_alias, 1) def _resolve_region(world_param: str | None, region_param: str | None) -> dict | None: """ Возвращает словарь chances из региона/плана либо None (глобальные шансы). world_param, region_param — сырые строки от нейросети (англ.). """ if not world_param: return None world_clean = _clean(world_param) resolved_world = WORLD_INDEX.get(world_clean) # Опечатка в имени мира — пытаемся исправить if resolved_world is None: best = _SYM.lookup(world_clean, Verbosity.CLOSEST, max_edit_distance=2) if best: resolved_world = WORLD_INDEX.get(best[0].term) if resolved_world is None: return None # План: сам мир и есть набор if resolved_world in PLANAR_OVERWRITES: return PLANAR_OVERWRITES[resolved_world]["chances"] # Прайм-мир: нужен регион if not region_param: return None scope = resolved_world reg_clean = _clean(region_param) region_key = REGION_INDEX.get((scope, reg_clean)) if region_key is None: best = _SYM.lookup(reg_clean, Verbosity.CLOSEST, max_edit_distance=2) if best: region_key = REGION_INDEX.get((scope, best[0].term)) if region_key is None: return None return REGIONAL_OVERWRITES[scope][region_key]["chances"] ALIGNMENTS = ["LG", "LN", "LE", "NG", "TN", "NE", "CG", "CN", "CE"] AGE_ORDER = list(AGES.keys()) # ["Youth", "Adult", "MiddleAged", "Old", "Ancient"] # ────────────────────────────────────────────────────────────────────────────── # Нормировка вероятностей # ────────────────────────────────────────────────────────────────────────────── def _normalize_chances(pool: dict[str, float]) -> dict[str, float]: """ Принимает {name: chance, ...}, возвращает нормированный словарь так, что сумма вероятностей == 100.0. Пример: A=3%, B=4% из 10 возможных. Доступны только A и B → их сумма = 7% → нормируем к 100%: A = 3/7*100 ≈ 42.86%, B = 4/7*100 ≈ 57.14% """ total = sum(pool.values()) if total == 0: # Равномерное распределение, если шансы нулевые n = len(pool) return {k: 100.0 / n for k in pool} if n else {} return {k: v / total * 100.0 for k, v in pool.items()} def _weighted_pick(normalized: dict[str, float]) -> str: """Выбирает ключ из нормированного словаря {name: chance_0_100}.""" r = random.random() * 100.0 cumulative = 0.0 for name, chance in normalized.items(): cumulative += chance if r <= cumulative: return name return list(normalized.keys())[-1] # ────────────────────────────────────────────────────────────────────────────── # Фильтрация пулов с последующей нормировкой # ────────────────────────────────────────────────────────────────────────────── def _filtered_race_chances(allowed_races: list[str]) -> dict[str, float]: """Возвращает нормированный пул рас из списка allowed_races.""" if not allowed_races: pool = {r: d["chance"] for r, d in RACES.items()} else: # Проверяем каждую разрешенную расу. Если её нет в базе — берем Human. processed_allowed = [] for r in allowed_races: if r in RACES: processed_allowed.append(r) else: processed_allowed.append("Human") pool = {r: RACES[r]["chance"] for r in processed_allowed if r in RACES} return _normalize_chances(pool) def _region_race_chances(region_chances: dict, allowed_races: list[str]) -> dict[str, float]: """ Строит нормированный пул рас на основе региональной перезаписи. Расы, не указанные в регионе, имеют шанс 0 (не попадают в пул). allowed_races (если задан) дополнительно сужает пул. """ pool = {r: d["chance"] for r, d in region_chances.items() if r in RACES} if allowed_races: pool = {r: c for r, c in pool.items() if r in allowed_races} return _normalize_chances(pool) def _region_subrace_chances(region_chances: dict, race_name: str) -> dict[str, float] | None: """ Возвращает нормированный пул подрас из региона для конкретной расы, либо None — тогда используются стандартные подрасы из RACES. """ entry = region_chances.get(race_name, {}) subs = entry.get("subraces") if not subs: return None return _normalize_chances(dict(subs)) def _filtered_age_chances(min_age: str | None, max_age: str | None) -> dict[str, float]: """ Возвращает нормированный пул возрастных категорий в диапазоне [min_age, max_age] по порядку в AGE_ORDER. """ min_idx = AGE_ORDER.index(min_age) if min_age and min_age in AGE_ORDER else 0 max_idx = AGE_ORDER.index(max_age) if max_age and max_age in AGE_ORDER else len(AGE_ORDER) - 1 allowed = AGE_ORDER[min_idx : max_idx + 1] pool = {age: AGES[age]["chance"] for age in allowed} return _normalize_chances(pool) def _filtered_language_chances(allowed_extra_langs: list[str]) -> dict[str, float]: """ Возвращает нормированный пул живых языков (без Common), ограниченный allowed_extra_langs (если задан). """ all_lang_pool = {l["name"]: l["chance"] for l in lang.living_languages_except_common} if not allowed_extra_langs: return _normalize_chances(all_lang_pool) pool = {name: chance for name, chance in all_lang_pool.items() if name in allowed_extra_langs} return _normalize_chances(pool) # ────────────────────────────────────────────────────────────────────────────── # Генерация одного NPC # ────────────────────────────────────────────────────────────────────────────── def _generate_single_npc( level: int, race_chances: dict[str, float], age_chances: dict[str, float], allowed_classes: list[str], commoner_chance: float, lang_chances: dict[str, float], gender_param: str = "Any", region_chances: dict | None = None, ) -> str: """Генерирует одного NPC и возвращает строку описания.""" # Пол if gender_param == "M": gender = "M" elif gender_param == "F": gender = "F" else: gender = "M" if random.random() < 0.68 else "F" # Раса race_name = _weighted_pick(race_chances) race_data = RACES[race_name] subrace_name = None # Приоритет: региональные подрасы, иначе стандартные из RACES region_subs = ( _region_subrace_chances(region_chances, race_name) if region_chances else None ) if region_subs: subrace_name = _weighted_pick(region_subs) elif "subraces" in race_data and race_data["subraces"]: sub_pool = _normalize_chances( {s: d["chance"] for s, d in race_data["subraces"].items()} ) subrace_name = _weighted_pick(sub_pool) race_obj = {"race": race_name, "subrace": subrace_name} # Имя full_name_obj = get_name(race_obj, gender) full_name = full_name_obj.get("name", "") # Языки расы known_race_languages = get_languages(race_obj, full_name_obj) known_lang_names = [l["name"] for l in known_race_languages if isinstance(l, dict)] # Класс — двухуровневый выбор: Commoner vs боевые классы char_class = get_random_class(allowed_classes, commoner_chance) # Характеристики ability_scores = get_ability_scores(char_class) # Возраст age_key = _weighted_pick(age_chances) age_data = AGES[age_key] aged_scores = dict(ability_scores) for stat, delta in age_data["modifiers"].items(): aged_scores[stat] = aged_scores.get(stat, 0) + delta # Бэкграунд (с учётом нормированного пула дополнительных языков) # Передаём уже известные языки расы для исключения дублей background = _get_background_with_filtered_languages( char_class, known_lang_names, lang_chances ) # Скиллы класса class_skills = get_class_skills_with_background(char_class, background) # Мировоззрение alignment = random.choice(ALIGNMENTS) # Психотип psycho = get_psychosocial_type_and_archetype() # Bio (XGE tables) bio = generate_bio(age_key) # ── Форматирование строки ────────────────────────────────────────────── race_str = f"**{subrace_name} {race_name}**" if subrace_name else f"**{race_name}**" race_str += " (" + _format_languages(known_race_languages) + ")" age_str = f"**{age_key}** ({age_data['explanation']})" attrs = ( f"Str {aged_scores['Strength']}, Dex {aged_scores['Dexterity']}, " f"Con {aged_scores['Constitution']}, Int {aged_scores['Intelligence']}, " f"Wis {aged_scores['Wisdom']}, Cha {aged_scores['Charisma']}" ) bg_langs_str = _format_languages(background["languages"]) bg_str = ( f"{background['name']} (" f"{', '.join(background['skills'])}, " f"{', '.join(background['tools'])}, " f"{bg_langs_str})" ) psycho_str = ( f"temperament: {psycho['temperament']['name']}, " f"psychosocial type: {psycho['psychosocialType']['name']}, " f"archetype by C.Jung: {psycho['archetype']['name']}" ) return ( f"**{char_class}** lvl {level}; {gender}; {full_name}; " f"race: {race_str}; age: {age_str}; {attrs}; al {alignment}; " f"class skills: {', '.join(class_skills)}; background: {bg_str}; {psycho_str}. {bio}" ) def _format_languages(languages) -> str: """Форматирует список языков.""" parts = [] for l in languages: if isinstance(l, dict): s = f"{l['name']} [{l['alphabet']}" if l.get("speakers"): s += f"; {l['speakers']}" s += "]" parts.append(s) else: parts.append(str(l)) return ", ".join(parts) def _get_background_with_filtered_languages( char_class: str, known_lang_names: list[str], lang_chances: dict[str, float], ) -> dict: """ Аналог get_background(), но при выборе языка бэкграунда использует нормированный пул lang_chances (ограниченный список). """ from backgrounds import ( BACKGROUNDS, _get_random_item_with_chance, _flatten, _select_random_elements, ) while True: group_items = [{"name": k, **v} for k, v in BACKGROUNDS.items()] selected_group = _get_random_item_with_chance(group_items) prof_items = [{"name": k, **v} for k, v in selected_group["list"].items()] prof = _get_random_item_with_chance(prof_items) prereqs = prof.get("prerequisite_classes", []) if prereqs and char_class not in prereqs: continue selected_skills = _select_random_elements(prof["skills"], 2) # Инструменты tools = _select_random_elements(prof.get("tools", []), 1) if prof.get("tools") else [] # Языки: выбираем из нормированного (ограниченного) пула, # исключая уже известные расовые extra_lang: list = [] if lang_chances: available_pool = {k: v for k, v in lang_chances.items() if k not in known_lang_names} if available_pool: renormed = _normalize_chances(available_pool) chosen_name = _weighted_pick(renormed) lang_obj = next( (l for l in lang.living_languages_except_common if l["name"] == chosen_name), None, ) if lang_obj: extra_lang = [lang_obj] return { "name": prof["name"], "skills": selected_skills, "tools": tools, "languages": extra_lang, } # ────────────────────────────────────────────────────────────────────────────── # Публичный метод тула # ────────────────────────────────────────────────────────────────────────────── class HenchmanConstraint(TypedDict, total=False): """ Параметры генерации одной группы NPC. Поля: gender: M, F, Any — по умолчанию Any allowed_races: Список разрешённых рас. Допустимы: Aasimar, Bugbear, Dragonborn, Dwarf, Elf, Firbolg, Genasi, Gith, Gnome, Goblin, Goliath, Half-Elf, Half-Orc, Halfling, Hobgoblin, Human, Kenku, Kobold, Leonin, Lizardfolk, Orc, Satyr, Tabaxi, Tiefling, Triton, Yuan-ti Pureblood. Пустой список = все расы. allowed_classes: Список разрешённых классов. Допустимы: Bard, Barbarian, Fighter, Wizard, Druid, Cleric, Warlock, Monk, Paladin, Rogue, Ranger, Sorcerer, Commoner. Пустой список = все классы (включая Commoner). commoner_chance: Вероятность того, что NPC окажется Commoner (0.0–1.0). Применяется только если Commoner входит в допустимые классы. По умолчанию 0.90. min_age: Минимальный возраст. Допустимы: Youth, Adult, MiddleAged, Old, Ancient. max_age: Максимальный возраст. Допустимы: Youth, Adult, MiddleAged, Old, Ancient. allowed_languages: Список дополнительных языков (кроме Common). Допустимы: Aglorondan, Alzhedo, Chessentan, Chondathan, Chultan, Damaran, Dambrathan, Durpari, Halruaan, Illuskan, Lantanese, Midani, Mulani, Mulhorandi, Mulhorandi (var.), Nexalan, Rashemi, Serusan, Shaaran, Koryo, Shou, Tashalan, Tuigan, Turmic, Uluik, Undercommon, Untheric, Dwarvish, Elvish, Draconic, Giant, Gnomish, Goblin, Halfling, Orc, Infernal, Celestial, Primordial, Sylvan, Abyssal, Gith, Leonin. Пустой список = все живые языки. count: Количество NPC для генерации. По умолчанию 1. level: Уровень NPC. По умолчанию 1. world: Космологический уровень сцены (англ.). Прайм-мир ("Toril", "Krynn", "Oerth") ЛИБО план существования ("feywild", "shadowfell", "lower_planes_le/ne/ce", "upper_planes_lg/ng/cg", "planes_of_chaos", "plane_of_law"). Пусто → глобальные шансы RACES. 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. Не определить → пусто (глобальные шансы). """ gender: str # "M", "F", "Any" — по умолчанию "Any" allowed_races: list[str] allowed_classes: list[str] commoner_chance: float min_age: str max_age: str allowed_languages: list[str] count: int level: int world: str region: str def generate_npc(debug_description: str, constraints: list[HenchmanConstraint]) -> str: """ Основной метод генерации NPC. Принимает список объектов-ограничений. Для каждого объекта: - нормирует вероятности рас, возрастов, языков относительно допустимого подмножества; - выбирает класс двухуровневым методом (Commoner vs боевые); - генерирует `count` NPC на уровне `level`. Возвращает Markdown-строку со всеми сгенерированными NPC. """ if not constraints: constraints = [{}] sections: list[str] = [] for idx, constraint in enumerate(constraints, start=1): allowed_races = constraint.get("allowed_races", []) min_age = constraint.get("min_age", None) max_age = constraint.get("max_age", None) allowed_classes = constraint.get("allowed_classes", []) allowed_langs = constraint.get("allowed_languages", []) commoner_chance = float( constraint.get("commoner_chance", DEFAULT_COMMONER_CHANCE)) count = int(constraint.get("count", 1)) level = int(constraint.get("level", 1)) gender_param = constraint.get("gender", "Any") # Региональная перезапись: разрешаем регион/план world_param = constraint.get("world", None) region_param = constraint.get("region", None) region_chances = _resolve_region(world_param, region_param) # Нормируем пулы — это ключевая логика: # Если задан регион — пул рас строится из него (остальные расы = 0), # иначе — стандартная фильтрация по allowed_races. if region_chances: race_chances = _region_race_chances(region_chances, allowed_races) else: race_chances = _filtered_race_chances(allowed_races) age_chances = _filtered_age_chances(min_age, max_age) lang_chances = _filtered_language_chances(allowed_langs) header = f"### Набор {idx} (lvl {level}, {count} шт.)\n" lines = [header] for j in range(1, count + 1): npc_line = _generate_single_npc( level, race_chances, age_chances, allowed_classes, commoner_chance, lang_chances, gender_param, region_chances, ) lines.append(f"#{j} — {npc_line}") sections.append("\n".join(lines)) return "\n\n---\n\n".join(sections) # ────────────────────────────────────────────────────────────────────────────── # Быстрый тест при запуске напрямую # ────────────────────────────────────────────────────────────────────────────── if __name__ == "__main__": result = generate_npc("", [ { "allowed_races": ["Human", "Elf", "Dwarf"], "min_age": "Youth", "max_age": "Old", "allowed_classes": ["Fighter", "Rogue", "Ranger", "Commoner"], "commoner_chance": 0.90, "allowed_languages":["Elvish", "Dwarvish", "Illuskan"], "count": 3, "level": 2, }, { # Без ограничений — случайные NPC, 90% коммонеры "count": 2, "level": 5, }, ]) print(result)