""" 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 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 _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", ) -> 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 if "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. """ 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 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") # Нормируем пулы — это ключевая логика: # Если из 10 доступных рас разрешены только 2, их относительные # шансы нормируются к 100% и рандом идёт только в этом подпространстве. 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, ) 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)