436 lines
20 KiB
Python
436 lines
20 KiB
Python
"""
|
||
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)
|