Initial commit

This commit is contained in:
dimitrievgs 2026-06-17 22:32:48 +03:00
commit e90a2e2e43
54 changed files with 14534 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
__pycache__
*.pyc

166
dice_roller/dice_roller.py Normal file
View File

@ -0,0 +1,166 @@
"""
dice_roller.py генератор бросков кубиков для D&D / настольных RPG.
Экспортирует функцию roll_dice(rolls), которую вызывает тул с фронта.
rolls список объектов вида:
{
"dice": "2d6+1d8+3", # выражение: кубики и модификаторы
"label": "Урон меча", # назначение броска (необязательно)
"count": 2, # сколько раз повторить бросок (default 1)
}
Возвращает строку в формате Markdown.
"""
import re
import random
from typing_extensions import TypedDict
# ──────────────────────────────────────────────────────────────────────────────
# Типы
# ──────────────────────────────────────────────────────────────────────────────
class RollRequest(TypedDict, total=False):
"""
Один запрос на бросок.
Поля:
dice: Выражение броска в стандартной dice notation.
Поддерживаются:
- кубики: NdM (например, 2d6, 1d20, 4d4)
- сложение: 2d6+1d8+3
- вычитание: 1d20-2
- только модификатор: 5 (без кубиков)
Примеры: "1d20", "2d6+3", "2d6+1d8", "1d20-2+1d4"
label: Человекочитаемое назначение броска.
Примеры: "Атака орка", "Урон двуручным мечом", "Спасбросок Ловкости"
По умолчанию пустая строка.
count: Сколько раз повторить этот бросок независимо.
Используется когда нужно несколько одинаковых бросков подряд
(например, 3 атаки одного существа: count=3).
По умолчанию 1.
"""
dice: str
label: str
count: int
# ──────────────────────────────────────────────────────────────────────────────
# Парсер и исполнитель выражения
# ──────────────────────────────────────────────────────────────────────────────
# Токен — это либо NdM, либо целое число, с опциональным знаком перед ним
_TOKEN_RE = re.compile(r'([+-]?)(\d+)(?:d(\d+))?', re.IGNORECASE)
def _parse_and_roll(expression: str) -> tuple[int, str]:
"""
Парсит dice notation и выполняет броски.
Поддерживает:
- кубики: 2d6, 1d20, 4d4
- модификаторы: +3, -2
- смешанные: 2d6+1d8+3, 1d20-2+1d4
Возвращает (итог: int, детали: str).
Детали строка вида:
"2d6[3,5]+1d8[7]+3 = 18"
"""
expression = expression.strip().replace(' ', '')
# Нормализуем: если выражение начинается не со знака — ставим '+'
if expression and expression[0] not in ('+', '-'):
expression = '+' + expression
total = 0
parts = [] # Для читаемого вывода
for match in _TOKEN_RE.finditer(expression):
sign_str, num_str, die_str = match.groups()
sign = -1 if sign_str == '-' else 1
num = int(num_str)
if die_str is not None:
# Это кубик: num d die
die = int(die_str)
rolls = [random.randint(1, die) for _ in range(num)]
value = sum(rolls) * sign
sign_prefix = '-' if sign == -1 else ('+' if parts else '')
rolls_str = ','.join(str(r) for r in rolls)
parts.append(f"{sign_prefix}{num}d{die}[{rolls_str}]")
else:
# Это чистый модификатор
value = num * sign
sign_prefix = '-' if sign == -1 else ('+' if parts else '')
parts.append(f"{sign_prefix}{num}")
total += value
detail = ''.join(parts) + f" = **{total}**"
return total, detail
# ──────────────────────────────────────────────────────────────────────────────
# Публичный метод тула
# ──────────────────────────────────────────────────────────────────────────────
def roll_dice(rolls: list[RollRequest]) -> str:
"""
Основной метод, который вызывает тул с фронта.
Принимает список объектов-запросов RollRequest.
Для каждого объекта выполняет бросок(ки) и возвращает
Markdown-строку с детализацией результатов.
"""
if not rolls:
return "Список бросков пуст. Укажи хотя бы один бросок."
sections: list[str] = []
for idx, request in enumerate(rolls, start=1):
dice_expr = request.get("dice", "1d6").strip()
label = request.get("label", "").strip()
count = int(request.get("count", 1))
header_label = f"{label}" if label else ""
header = f"### Бросок {idx}{header_label} (`{dice_expr}`)"
lines = [header]
roll_total = 0
for j in range(1, count + 1):
total, detail = _parse_and_roll(dice_expr)
roll_total += total
prefix = f"#{j}: " if count > 1 else ""
lines.append(f"{prefix}{detail}")
if count > 1:
lines.append(f"**Сумма всех бросков: {roll_total}**")
sections.append("\n".join(lines))
return "\n\n---\n\n".join(sections)
# ──────────────────────────────────────────────────────────────────────────────
# Быстрый тест при запуске напрямую
# ──────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
result = roll_dice([
{"dice": "1d20+5", "label": "Атака орка", "count": 3},
{"dice": "2d6+1d8+2", "label": "Урон двуручным мечом", "count": 1},
{"dice": "1d20", "label": "Спасбросок Ловкости", "count": 1},
{"dice": "4d6", "label": "Генерация характеристик","count": 6},
{"dice": "1d20-2", "label": "Проверка с помехой", "count": 1},
])
print(result)

33
game_utils.py Normal file
View File

@ -0,0 +1,33 @@
def get_weather(city: str, tone: str = "обычный"):
"""
Получает прогноз погоды для города.
:param city: Название города на русском языке.
:param tone: Стиль описания (мрачный, веселый).
"""
# Логика скрипта
return f"В городе {city} сейчас дождь. Тон: {tone}"
def roll_dice(dice_exp: str):
"""
Бросает кубики (например, '2d20 + 5').
"""
import random
# Парсинг и бросок...
return "Выпало 18"
def main(dice_exp2: str):
"""
Бросает кубики (например, '2d20 + 5').
"""
import random
# Парсинг и бросок...
return "Выпало 114"
def create_ch(name: str, appearance: str):
"""
Бросает кубики (например, '2d20 + 5').
"""
import random
# Парсинг и бросок...
return f"имечко {name}, внешка: {appearance}"

View File

@ -0,0 +1,69 @@
Для создания системы, которая будет работать как надежный Python-скрипт и выдавать данные для нейросети, нам нужно абстрагироваться от конкретных существ и сосредоточиться на **состоянии мира**.
Хуки (Hooks) должны быть «контейнерами», которые описывают **контекст происходящего**, но оставляют вопрос «кто в этом участвует» и «как они настроены» на долю генераторов (Beasts/NPC) и бросков Реакции.
Вот классификация и обобщенные хуки, расширенные на основе данных `dmg_encounters.txt`:
---
### 1. PvP (Персонаж против Персонажа/Существа)
**Суть:** Столкновение воль. Существа заняты своим делом, в которое втягиваются герои.
**Логика для Питона:** Генерирует 1d4+1 NPC (через `henchmen.py`) или Монстров (через `encounter_generator.py`).
**Хуки (Нейтральные к Реакции):**
* **The Claim (Притязание):** Существа охраняют территорию, пост или право прохода. (При Hostile — требуют плату/атакуют; при Friendly — делятся советом о дороге).
* **The Task (Поручение/Призыв):** Существа находятся при исполнении обязанностей (патруль, сбор налогов, охота). Они оценивают героев как ресурс или помеху.
* **The Internal Conflict (Свалка):** Группа существ конфликтует между собой. Герои могут стать судьями, жертвами или союзниками. (Темы: Brawl, Protest, Patrol vs Raiders).
* **The Shadow (Слежка/Промысел):** Существа скрытно наблюдают за героями или пытаются совершить скрытое действие (Pickpocket, Shady transaction, Spy).
* **The Interaction (Предложение/Запрос):** Существа активно ищут контакта для обмена, торговли или получения помощи (Merfolk traders, Companion, Urchin).
### 2. PvE (Персонаж против Среды)
**Суть:** Опасность или изменение ландшафта. Живые существа здесь — лишь статисты или жертвы, не влияющие на саму угрозу.
**Логика для Питона:** Не требует бросков Реакции для самой угрозы.
**Хуки (Нейтральные к Реакции):**
* **The Catastrophe (Катастрофа):** Внезапное изменение среды, требующее немедленного действия (Fire, Whirlpool, Sandstorm, Iceberg).
* **The Obstacle (Препятствие):** Дорога перекрыта чем-то масштабным и движущимся (Procession, Runaway cart, огромный Kelp bed, миграция животных).
* **The Anomaly (Аномалия):** Энергетический или природный всплеск (Steam vent, Grove of burned trees, Blizzard, Волшебное пение на ветру).
* **The Landmark (Ориентир):** Место, меняющее восприятие пути (Statue, Monolith, Sunken ruins, Abandoned grove). Были ли здесь враги — решит `Discovery`.
### 3. PvX (Персонаж против Неизвестного)
**Суть:** Тайна, след или ресурс. Существа здесь скрыты, либо являются объектом изучения.
**Логика для Питона:** Бросок Surprise определяет, найдут ли герои «источник», прежде чем он найдет их.
**Хуки (Нейтральные к Реакции):**
* **The Remnant (Останки/След):** Свидетельство недавнего присутствия существ (Corpse, Floating debris, Tracks, Ghost ship).
* **The Cache (Схрон/Ресурс):** Ожидаемая или случайная находка (Found trinket, Sunken treasure chest, Magical plant/berries, Bed of giant oysters).
* **The Lair (Логово):** Герои натыкаются на место обитания (Undersea cave, Hidden nest, Shady lair). Кто внутри — решит бросок монстров.
* **The Omen (Знамение/Тень):** Неясный визуальный или звуковой сигнал (Shadow in the water, Sighting of a killer whale, Distant horn sound).
### 4. PvSelf (Персонаж против Себя)
**Суть:** Дилемма. Ситуация, требующая выбора между риском, совестью и выгодой.
**Логика для Питона:** Обычно один NPC или слабое существо.
**Хуки (Нейтральные к Реакции):**
* **The Plea (Просьба о спасении):** Существо в беде (NPC in the water, Wounded scout, Beggar). Помощь сулит награду или проблемы.
* **The Moral Crossroads (Моральный перекресток):** Ситуация несправедливости, свидетелями которой стали герои (Execution, Bullies vs Commoner, Unfair decree).
* **The Temptation (Искушение):** Возможность получить что-то ценное, нарушив закон или обычаи (Found trinket, принадлежащий храму; брошенная повозка с золотом).
---
### Как это работает в Питоне (Пример логики):
1. **Скрипт выбирает Тип:** `PvP`.
2. **Скрипт выбирает Хук:** `The Task (Поручение)`.
3. **Скрипт выбирает Субъектов:** Из таблицы биома `Forest` выпадают `2d4 scouts`.
4. **Скрипт бросает ACKS II:**
* *Reaction* = 3 (Hostile).
* *Surprise* = Party is Attentive.
5. **Формирование вывода для Нейронки:**
* **Вектор:** PvP / The Task.
* **Объект:** Группа эльфов-разведчиков (Scouts).
* **Состояние:** Реакция враждебная (Hostile). Разведчики выполняют патрулирование границ и воспринимают героев как нарушителей/шпионов. Благодаря Внимательности, герои видят, как разведчики готовят луки.
---
### Обобщение Hooks по биомам (Примеры для базы данных):
* **Sea (PvX/Remnant):** «Среди волн покачиваются обломки судна и куски парусины (Floating debris). Что-то темное движется под водой...»
* **Sylvan Forest (PvP/The Internal Conflict):** «Звуки стычки доносятся из-за вековых дубов. Группа лесных существ (Gnolls) что-то не поделила с местными обитателями...»
* **Undersea (PvE/The Anomaly):** «Вода внезапно становится обжигающе горячей, вверх поднимаются пузыри пара (Steam vent). Видимость падает...»
* **Urban (PvSelf/The Moral Crossroads):** «На площади стража готовит плаху для осужденного (Spectacle/Execution). Толпа замерла в ожидании...»
Эта система полностью нейтральна: **The Task** может обернуться как дружеским приветствием патруля (Reaction 12), так и попыткой ареста (Reaction 2). Скрипт просто дает «сцену», а кубики — «драму».

View File

@ -0,0 +1,203 @@
"""
encounter_generator.py генератор случайных встреч по биомам и уровням с интеграцией механик ACKS II (Surprise & Reaction).
Экспортирует функцию generate_encounter(debug_description, requests), которую вызывает тул.
requests список объектов вида:
{
"environment": "forest",
"level": 5,
"party_surprise_mods": {"Высокое восприятие": 1},
"monster_surprise_mods": {"Скрытность варвара": -2},
"reaction_mods": {"Харизма жреца": 2, "Известный враг": -1},
"party_foreknowledge": False,
"party_los": True,
"monster_foreknowledge": False,
"monster_los": True
}
Возвращает строку в формате Markdown с полным отчетом по событию, внезапности и реакции.
"""
import random
from typing import Dict, Optional
from typing_extensions import TypedDict
# ──────────────────────────────────────────────────────────────────────────────
# Таблицы данных
# ──────────────────────────────────────────────────────────────────────────────
from monsters_data import DATA
# (В РЕАЛЬНОМ СКРИПТЕ ВСЕ ТАБЛИЦЫ ДОЛЖНЫ БЫТЬ ПРОПИСАНЫ ПОЛНОСТЬЮ. ДЛЯ ЭКОНОМИИ МЕСТА Я ПРИВЕЛ ПЕРВЫЕ 6,
# НО ГУМАНОИДНЫЙ АГЕНТ ДОЛЖЕН РАЗВЕРНУТЬ ИХ ВСЕ. НИЖЕ ЛОГИКА ФУНКЦИИ.)
class EncounterRequest(TypedDict, total=False):
"""
Параметры запроса случайной встречи с учетом контекста столкновения.
Поля:
environment: Биом (arctic, coastal, desert, forest, grassland, hill, mountain, swamp, underdark, underwater, urban).
level: Средний уровень группы (1-20).
По умолчанию: 1.
party_surprise_mods: Словарь модификаторов броска внезапности ПАРТИИ (Ключ - причина, Значение - бонус/штраф).
Пример: {"Бдительность": 1}. По умолчанию: {}.
monster_surprise_mods: Словарь модификаторов броска внезапности МОНСТРОВ.
Пример: {"Скрытность эльфа": -2}. По умолчанию: {}.
reaction_mods: Словарь модификаторов броска реакции.
Пример: {"Харизма партийного лица": 2}. По умолчанию: {}.
party_foreknowledge: Есть ли у партии предзнание (Foreknowledge)?
По умолчанию: False.
party_los: Есть ли у партии прямая видимость (LOS) в начале?
По умолчанию: True.
monster_foreknowledge: Есть ли у монстров предзнание?
По умолчанию: False.
monster_los: Есть ли у монстров прямая видимость в начале?
По умолчанию: True.
"""
environment: str
level: int
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 _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, los: bool, side_name: str) -> str:
"""
Рассчитывает внезапность для одной из сторон на основе частичной логики матрицы ACKS.
Если есть предзнание, бросок не требуется. Иначе кидается 1d6.
"""
if foreknowledge:
return f"**{side_name.capitalize()}:** Не застигнуты врасплох (Было предзнание/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)"
mods_str = _format_mods(mods)
return (f"**{side_name.capitalize()}:** {status} "
f"(Бросок: 1d6 ({roll}) + Мод ({mod_sum}) = {total}) | Модификаторы: {mods_str}")
def _determine_reaction(mods: Dict[str, int]) -> str:
"""
Рассчитывает первичную реакцию монстров броском 2d6 по правилам ACKS.
Учитывает пределы (немодифицированные 2 и 12 имеют ограничения).
"""
mod_sum = sum(mods.values()) if mods else 0
roll = random.randint(1, 6) + random.randint(1, 6)
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:
res = "Враждебные (Hostile) — Немедленно атакуют!"
elif total <= 5:
res = "Недружелюбные (Unfriendly) — Не доверяют и могут атаковать при шансе на победу."
elif total <= 8:
res = "Нейтральные (Neutral) — Не уверены; реакция зависит от действий партии."
elif total <= 11:
res = "Индифферентные (Indifferent) — Проигнорируют, если их не тревожить."
else:
res = "Дружелюбные (Friendly) — Заинтересованы в сотрудничестве или разговоре."
mods_str = _format_mods(mods)
return (f"**Итоговая реакция:** {res}\n"
f"- **Сырой бросок:** 2d6 ({roll})\n"
f"- **Сумма модификаторов:** {mod_sum} ({mods_str})\n"
f"- **Итоговое значение:** {total}")
def generate_encounter(debug_description: str, requests: list[EncounterRequest]) -> str:
"""
Основной метод, который вызывает тул.
Принимает список запросов. Для каждого генерирует:
- Базовую встречу;
- Исходы проверки на сюрприз для партий и монстров;
- Социальную реакцию существ (если не сказано обратное).
Возвращает Markdown-строку.
"""
if not requests:
requests = [{}]
results = []
for i, req in enumerate(requests, 1):
env = req.get("environment", "forest")
lvl = int(req.get("level", 1))
# Получаем данные о встрече
encounter_text = _get_encounter(env, lvl)
# Обработка Внезапности
p_mods = req.get("party_surprise_mods", {})
m_mods = req.get("monster_surprise_mods", {})
p_fore = req.get("party_foreknowledge", False)
p_los = req.get("party_los", True)
m_fore = req.get("monster_foreknowledge", False)
m_los = req.get("monster_los", True)
p_surprise = _determine_surprise(p_mods, p_fore, p_los, "Партия")
m_surprise = _determine_surprise(m_mods, m_fore, m_los, "Монстры")
# Обработка Реакции
r_mods = req.get("reaction_mods", {})
reaction_text = _determine_reaction(r_mods)
# Формирование отчета
res = (f"### Встреча №{i}\n"
f"- **Биом:** {env.capitalize()}\n"
f"- **Уровень группы:** {lvl}\n"
f"- **Результат по таблицам:** {encounter_text}\n\n"
f"#### Проверка Внезапности (Surprise)\n"
f"- {p_surprise}\n"
f"- {m_surprise}\n\n"
f"#### Первичная Реакция (Reaction)\n"
f"{reaction_text}")
results.append(res)
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": "arctic",
"level": 1
}
]
print(generate_encounter("Тестовая генерация энкаунтера", test_reqs))

View File

@ -0,0 +1,465 @@
"""
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
# ──────────────────────────────────────────────────────────────────────────────
# TypedDict
# ──────────────────────────────────────────────────────────────────────────────
class EncounterRequest(TypedDict, total=False):
"""
Параметры одного запроса случайной встречи.
Поля:
environment: Биом.
Допустимы: arctic, coastal, desert, forest,
grassland, hill, mountain, swamp, underdark,
underwater, urban, nine_hells, astral_sea, feywild,
plane_of_air, plane_of_earth, plane_of_fire, plane_of_water.
По умолчанию: forest.
level: Средний уровень группы (120).
По умолчанию: 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
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) -> 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 = []
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(env, level)
elif source == "npc":
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,
}],
)
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))
# Считываем явно заданный тип или None
req_type = req.get("encounter_type")
# ── Тип и хук ────────────────────────────────────────────────────────
enc_type = _pick_encounter_type(env, 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_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", {}))
# ── Сборка секции ─────────────────────────────────────────────────────
section = (
f"### Встреча №{i}\n"
f"- **Биом:** {env.capitalize()}\n"
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))

View File

@ -0,0 +1,97 @@
"""
get_creature.py выбор случайного существа/группы из таблиц биома.
Вынесено из encounter_generator.py.
Экспортирует:
get_creature(env, level) -> str
Возвращает строку из таблицы монстров для биома и уровня.
"""
import random
from monsters_data import DATA
# ──────────────────────────────────────────────────────────────────────────────
# Внутренние функции
# ──────────────────────────────────────────────────────────────────────────────
def _resolve_level_key(env: str, level: int) -> str:
"""
Определяет ключ диапазона уровней для данного биома.
Разные биомы имеют разные диапазоны в таблицах DMG.
"""
if env == "grassland":
if level <= 5: return "1-5"
if level <= 10: return "6-10"
if level <= 16: return "11-16"
return "17-20"
if env in ("swamp", "underwater"):
if level <= 4: return "1-4"
if level <= 10: return "5-10"
return "11-20"
# Все остальные биомы
if level <= 4: return "1-4"
if level <= 10: return "5-10"
if level <= 16: return "11-16"
return "17-20"
def _roll_on_table(table: list[tuple[int, int, str]]) -> str:
"""
Бросает d100 и возвращает результат из таблицы.
Таблица: список кортежей (min, max, text).
"""
die_roll = random.randint(1, 100)
for start, end, text in table:
if start <= die_roll <= end:
return f"(d100: {die_roll}) — {text}"
return f"(d100: {die_roll}) — результат не найден в таблице."
# ──────────────────────────────────────────────────────────────────────────────
# Публичная функция
# ──────────────────────────────────────────────────────────────────────────────
def get_creature(env: str, level: int) -> str:
"""
Возвращает случайное существо/группу из таблицы биома и уровня.
Args:
env: Биом (arctic, coastal, desert, forest, ...).
level: Средний уровень группы (120).
Returns:
Строка вида "(d100: 42) — 2d6 bandits" или сообщение об ошибке.
"""
env = env.lower().strip()
if env not in DATA:
return f"[get_creature] Биом '{env}' не найден в таблицах."
key = _resolve_level_key(env, level)
table = DATA.get(env, {}).get(key)
if not table:
return f"[get_creature] Таблица для биома '{env}', уровни '{key}' не найдена."
return _roll_on_table(table)
# ──────────────────────────────────────────────────────────────────────────────
# Быстрый тест
# ──────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
tests = [
("forest", 3),
("arctic", 1),
("urban", 10),
("grassland", 6),
("underwater", 15),
("unknown", 5),
]
for biome, lvl in tests:
print(f"[{biome:12} lvl {lvl:2}] {get_creature(biome, lvl)}")

View File

@ -0,0 +1,290 @@
"""
get_npc_races.py маппинг биома на типичные расы NPC.
По аналогии с get_creature.py: используется encounter_generator
при вызове get_npc, чтобы ограничить пул рас биомом.
Пустой список (или отсутствующий биом) = все расы без ограничений.
"""
NPC_RACES_BY_ENV: dict[str, list[str]] = {
"arctic": [
"Human", # основное население севера
"Goliath", # горные и арктические кочевники
"Half-Orc", # выносливые, часто в северных племенах
"Dwarf", # горные кланы у края ледников
"Orc", # северные племена
"Genasi", # воздушные и водные генази — редко, но возможно
"Tiefling", # изгнанники и одиночки
],
"coastal": [
"Human", # рыбаки, моряки, торговцы
"Half-Elf", # портовые города полны полукровок
"Triton", # прибрежные общины, выходящие на сушу
"Genasi", # водные генази — у моря естественны
"Dwarf", # морские торговцы
"Halfling", # торговые суда, таверны
"Gnome", # механики, картографы
"Tabaxi", # странствующие торговцы с далёких берегов
"Tiefling", # портовые города терпимы к чужакам
"Half-Orc", # докеры, наёмники
"Kenku", # воры и информаторы в портах
"Lizardfolk", # прибрежные болота рядом
"Orc", # пираты, наёмные моряки
"Aasimar", # редко, но встречаются
],
"desert": [
"Human", # основное население пустынь
"Genasi", # огненные и воздушные — у себя дома
"Tabaxi", # кочевые охотники
"Tiefling", # торговые пути, одиночки
"Half-Elf", # торговцы на маршрутах
"Dragonborn", # кланы в руинах древних империй
"Gnome", # алхимики, искатели артефактов
"Yuan-ti Pureblood", # их цивилизации исторически в пустынях
"Kobold", # в руинах и пещерах под песком
"Halfling", # торговые караваны
"Aasimar", # странники, несущие миссию
"Half-Orc", # наёмники охраны каравана
"Lizardfolk", # оазисы, реки
],
"forest": [
"Human", # охотники, дровосеки, деревни
"Elf", # лесные общины — естественная среда
"Half-Elf", # дети смешанных союзов
"Firbolg", # хранители леса
"Gnome", # лесные гномы в чаще
"Halfling", # деревни на опушках
"Wood Elf", # лесные эльфы
"Goblin", # банды в чаще
"Kobold", # норы под корнями
"Tabaxi", # охотники-одиночки
"Kenku", # падальщики и воры
"Half-Orc", # изгнанники, охотники
"Orc", # племена в глубоком лесу
"Tiefling", # отшельники, беглецы
"Aasimar", # странники
"Lizardfolk", # у болотистых рек
"Dragonborn", # редкие кланы
"Satyr", # фейские опушки
"Genasi", # природные генази
],
"grassland": [
"Human", # фермеры, пастухи, кочевники
"Halfling", # сельские общины
"Half-Orc", # племена степей
"Orc", # кочевые орды
"Gnome", # деревни торговцев
"Half-Elf", # странствующие торговцы
"Goliath", # кочевые воины
"Tabaxi", # охотники на просторах
"Dragonborn", # кланы воинов
"Tiefling", # одиночки среди людских общин
"Kenku", # у дорог и трактов
"Kobold", # норы в холмах
"Aasimar", # редкие странники
"Leonin", # прайды в открытых равнинах — их естественная среда
"Genasi", # воздушные генази
],
"hill": [
"Human", # деревни и фермы
"Dwarf", # холмовые кланы
"Halfling", # норы в холмах — классическая среда
"Gnome", # холмовые гномы
"Half-Orc", # наёмники и изгнанники
"Orc", # племена холмов
"Goblin", # многочисленны в холмах
"Hobgoblin", # военные лагеря
"Kobold", # шахты и норы
"Tiefling", # одиночки
"Half-Elf", # торговцы между деревнями
"Dragonborn", # воинские кланы
"Goliath", # у предгорий
"Kenku", # у дорог
"Aasimar", # редко
],
"mountain": [
"Human", # горные деревни, монастыри
"Dwarf", # горные твердыни — их дом
"Goliath", # высокогорные кочевники
"Dragonborn", # горные кланы
"Gnome", # шахтёры
"Half-Orc", # горные наёмники
"Orc", # горные племена
"Tiefling", # отшельники в горных монастырях
"Aasimar", # паломники, отшельники
"Kobold", # в шахтах и пещерах
"Goblin", # в расщелинах
"Hobgoblin", # военные форпосты
"Genasi", # земляные и огненные
"Kenku", # у горных троп
],
"swamp": [
"Human", # рыбаки, браконьеры, отшельники
"Lizardfolk", # болотные общины — их естественная среда
"Yuan-ti Pureblood", # древние культы в руинах
"Genasi", # водные генази
"Half-Orc", # изгнанники
"Goblin", # болотные банды
"Kobold", # в норах у воды
"Tiefling", # отшельники и культисты
"Kenku", # падальщики
"Orc", # племена болот
"Gnome", # алхимики, охотники за ингредиентами
"Half-Elf", # искатели приключений
"Tabaxi", # охотники
"Aasimar", # редко, миссионеры
],
"underdark": [
"Dwarf", # дуэргары и обычные дварфы в приграничных крепостях
"Gnome", # глубинные гномы (svirfneblin)
"Elf", # дроу
"Half-Elf", # потомки дроу и поверхностных рас
"Goblin", # многочисленны в пещерах
"Hobgoblin", # военные отряды
"Kobold", # повсюду в туннелях
"Orc", # глубинные кланы
"Half-Orc", # среди орочьих кланов
"Tiefling", # культисты, беглецы с поверхности
"Genasi", # земляные и огненные у жерл
"Aasimar", # редчайшие — пленники или странники
"Dragonborn", # редкие кланы, связанные с глубинными драконами
"Gith", # githyanki и githzerai проходят через Андердарк
"Human", # шахтёры, искатели, беглецы с поверхности
"Bugbear", # охотники в темноте
],
"underwater": [
"Triton", # подводные общины — их дом
"Genasi", # водные генази
"Human", # редко, с магической защитой
"Half-Elf", # с зельями или артефактами
"Aasimar", # миссионеры из морских храмов
],
"urban": [
"Human", # большинство населения городов
"Half-Elf", # везде как рыба в воде
"Dwarf", # торговцы, ремесленники
"Gnome", # изобретатели, алхимики, иллюзионисты
"Halfling", # воры, торговцы, слуги
"Tiefling", # городская среда терпима или враждебна — в любом случае они здесь
"Half-Orc", # охранники, докеры, наёмники
"Elf", # послы, маги, торговцы
"Dragonborn", # воины, гильдейские члены
"Aasimar", # жрецы, паладины
"Kenku", # воры, информаторы, гонцы
"Goblin", # трущобы, подземелья под городом
"Kobold", # слуги, механики в мастерских
"Tabaxi", # торговцы из дальних стран
"Orc", # охранники, строители
"Bugbear", # бандиты, вышибалы
"Hobgoblin", # наёмные солдаты, городская стража
"Genasi", # маги, ремесленники
"Lizardfolk", # редко, торговые посольства
"Yuan-ti Pureblood", # инфильтраторы в богатых кварталах
"Gith", # очень редко, искатели артефактов
"Satyr", # артисты, бродяги
],
"nine_hells": [
"Tiefling", # чувствуют зов крови
"Human", # авантюристы, культисты, проклятые
"Aasimar", # пленники или посланники
"Dragonborn", # кланы, заключившие инфернальные договоры
"Half-Elf", # искатели запретного знания
"Gith", # githyanki — союзники Тиамат
"Genasi", # огненные генази
"Half-Orc", # наёмники на инфернальной службе
],
"astral_sea": [
"Human", # авантюристы, искатели
"Gith", # githyanki — хозяева Астрального моря
"Aasimar", # небесные странники
"Tiefling", # торговцы между планами
"Genasi", # воздушные генази
"Half-Elf", # дипломаты между планарными фракциями
"Dragonborn", # планарные рыцари
"Gnome", # исследователи и картографы планов
],
"feywild": [
"Elf", # тесно связаны с Феидом
"Half-Elf", # притянуты фейской кровью
"Gnome", # лесные гномы — почти фейские существа
"Satyr", # их родной план
"Tiefling", # изгнанники, ищущие красоты
"Halfling", # любопытные путешественники
"Human", # заблудившиеся или призванные
"Firbolg", # хранители фейских рощ
"Aasimar", # посланники в фейские дворы
"Genasi", # природные генази
"Tabaxi", # очарованные странники
"Dragonborn", # редко, по древним договорам
],
"plane_of_air": [
"Aarakocra", # коренные обитатели облачных городов
"Genasi", # воздушные генази, рождённые стихией
"Djinn", # правят дворцами, редко общаются с чужаками
"Elf", # искатели знаний, пилоты летучих кораблей
"Human", # купцы, наёмники, беглецы
"Tiefling", # дипломаты или изгнанники
"Aasimar", # посланники богов ветра
"Gith", # githyanki рейдеры, githzerai отшельники
"Gnome", # изобретатели летательных аппаратов
"Halfling", # торговцы на воздушных рынках
"Kenku", # шпионы и воры при дворах джиннов
"Tabaxi", # охотники на высоте, ценят свободу
"Dragonborn", # воины, связанные с небесными драконами
],
"plane_of_earth": [
"Dwarf", # хозяева подземных твердынь, шахтёры
"Gnome", # глубинные гномы, камнерезы и иллюзионисты
"Genasi", # земляные генази, хранители пещер
"Goliath", # кочевники горных хребтов
"Half-Orc", # наёмники, охрана караванов
"Orc", # племена в глубоких пещерах
"Dragonborn", # кланы, связанные с драконами земли
"Tiefling", # искатели древней силы
"Aasimar", # паломники к священным местам
"Goblin", # банды в туннелях
"Kobold", # мастера ловушек и шахтёры
"Hobgoblin", # военные отряды
"Dao", # джинны земли, правители дворцов
"Duergar", # тёмные дварфы, злобные обитатели глубин
],
"plane_of_fire": [
"Genasi", # огненные генази, коренные жители
"Tiefling", # заключают сделки с эфритами
"Dragonborn", # рыцари, связанные с драконами огня
"Azer", # огненные дварфы, кузнецы
"Efreet", # джинны огня, правители-тираны
"Half-Orc", # наёмники в армиях эфритов
"Orc", # племена у жерл вулканов
"Goblin", # слуги и рабочая сила
"Kobold", # хранители сокровищ в горячих пещерах
"Aasimar", # посланники добра, пытаются смягчить тиранию
"Human", # авантюристы, купцы, беженцы
"Dwarf", # горные дварфы, искатели редких руд
"Gith", # рейдеры или отшельники
],
"plane_of_water": [
"Triton", # коренные обитатели, стражи глубин
"Genasi", # водные генази, лоцманы и целители
"Elf", # морские эльфы, кочевники рифов
"Human", # моряки, торговцы, пираты
"Aasimar", # посланники морских божеств
"Tiefling", # изгои или пираты глубин
"Dragonborn", # воины, связанные с морскими драконами
"Lizardfolk", # охотники на болотах и мелководье
"Dwarf", # подводные крепости, добытчики жемчуга
"Gnome", # изобретатели подводных аппаратов
"Marid", # джинны воды, своенравные правители
"Merfolk", # рыбаки, певцы, хранители знаний
],
}
def get_npc_races(env: str) -> list[str]:
"""
Возвращает список рас для биома.
Если биом не найден или список пустой возвращает [],
что в get_npc означает 'все расы'.
"""
return NPC_RACES_BY_ENV.get(env.lower().strip(), [])

View File

@ -0,0 +1,323 @@
"""
hooks_data.py данные о хуках и весах типов встреч.
Содержит:
- ENCOUNTER_TYPE_WEIGHTS: веса типов встреч по биому.
- HOOK_WEIGHTS: веса хуков внутри каждого типа (универсальные).
- HOOKS: схема субъектов для каждого хука.
"""
# ──────────────────────────────────────────────────────────────────────────────
# Веса ТИПОВ встреч по биому/плану.
# Добавление нового плана = одна новая строка здесь.
# ──────────────────────────────────────────────────────────────────────────────
ENCOUNTER_TYPE_WEIGHTS: dict[str, dict[str, int]] = {
"arctic": {"PvP": 50, "PvE": 25, "PvX": 20, "PvSelf": 5},
"coastal": {"PvP": 45, "PvE": 20, "PvX": 30, "PvSelf": 5}, # 10
"desert": {"PvP": 45, "PvE": 30, "PvX": 20, "PvSelf": 5}, # 10
"forest": {"PvP": 50, "PvE": 20, "PvX": 25, "PvSelf": 5}, # 10
"grassland": {"PvP": 50, "PvE": 20, "PvX": 25, "PvSelf": 5}, # 10
"hill": {"PvP": 50, "PvE": 20, "PvX": 25, "PvSelf": 5}, # 10
"mountain": {"PvP": 40, "PvE": 30, "PvX": 25, "PvSelf": 5},
"swamp": {"PvP": 35, "PvE": 25, "PvX": 35, "PvSelf": 5},
"underdark": {"PvP": 50, "PvE": 15, "PvX": 30, "PvSelf": 5},
"underwater": {"PvP": 30, "PvE": 35, "PvX": 30, "PvSelf": 5},
"urban": {"PvP": 40, "PvE": 15, "PvX": 25, "PvSelf": 20}, # 45
# Планы — новые строки:
"nine_hells": {"PvP": 55, "PvE": 25, "PvX": 15, "PvSelf": 5},
"astral_sea": {"PvP": 20, "PvE": 40, "PvX": 35, "PvSelf": 5},
"feywild": {"PvP": 35, "PvE": 25, "PvX": 30, "PvSelf": 10}, # 25
"plane_of_air": {"PvP": 35, "PvE": 30, "PvX": 30, "PvSelf": 5},
"plane_of_earth": {"PvP": 40, "PvE": 30, "PvX": 25, "PvSelf": 5},
"plane_of_fire": {"PvP": 45, "PvE": 25, "PvX": 25, "PvSelf": 5},
"plane_of_water": {"PvP": 35, "PvE": 30, "PvX": 30, "PvSelf": 5},
}
# ──────────────────────────────────────────────────────────────────────────────
# Веса ХУКОВ внутри каждого типа.
# Универсальны — не зависят от биома или плана.
# ──────────────────────────────────────────────────────────────────────────────
HOOK_WEIGHTS: dict[str, dict[str, int]] = {
"PvP": {
"The Hunt": 18, # Охота, засада, набег
"The Routine": 20, # Патруль, миграция, фуражирование
"The Claim": 15, # Охрана территории/чека
"The Social Event": 12, # Процессия, ритуал, состязание
"The Interaction": 12, # Поиск контакта (торговля, помощь)
"The Internal Conflict": 10, # Две группы дерутся
"The Shadow": 7, # Скрытое наблюдение, шпионаж
"The Chase": 5,
"The Challenge": 1,
},
"PvE": {
"The Sudden Crisis": 28, # Сбежавшие животные, пожар, телега
"The Obstacle": 25, # Блок пути (завал, айсберг, водоросли)
"The Hazard": 18, # Опасная зона (водоворот, гейзер)
"The Anomaly": 17, # Дикая магия, корабль-призрак
"The Weather": 12,
},
"PvX": {
"The Landmark": 20, # Пустые руины, статуя, кораблекрушение
"The Remnant": 25, # Трупы, следы, обломки
"The Cache": 25, # Сокровище, жемчуг, магические ягоды
"The Lair": 20, # Обитаемая пещера, логово
"The Omen": 10, # Звуки, предзнаменования, эльфийская песня
},
"PvSelf": {
"The Plea": 30, # Выживший в воде, сирота
"The Moral Crossroads": 35, # Задиры, произвол стражи, призыв
"The Temptation": 22, # Теневая сделка, бесхозный лут
"The Betrayal": 13,
},
}
# ──────────────────────────────────────────────────────────────────────────────
# Схема субъектов для каждого хука.
#
# subject_sources — список слотов субъектов. Каждый слот:
# source: "creatures" → get_monsters_or_animals()
# "npc" → get_npc()
# count: строка с dice-нотацией или "1"
# role: роль в сцене (для нейросети): "primary", "aggressor",
# "defender", "victim", "bystander"
# optional: если True — слот появляется с вероятностью 50%
#
# latent_threat: True → существа могут появиться как следствие
# провала броска Surprise (решается в encounter_generator)
# ──────────────────────────────────────────────────────────────────────────────
HOOKS: dict[str, dict[str, dict]] = {
"PvP": {
"The Hunt": {
"description": "Целенаправленная атака, засада или поиск добычи (хищники, пираты, бандиты).",
"subject_sources": [
{"source": "creatures", "count": "1d4+1", "role": "aggressor"},
],
"latent_threat": False,
},
"The Routine": {
"description": "Существа заняты своей жизнью: миграция, патруль, фуражирование, прогулка.",
"subject_sources": [
{"source": "creatures", "count": "1d4+1", "role": "primary"},
],
"latent_threat": False,
},
"The Claim": {
"description": "Существа активно охраняют логово, проход, предмет или территорию.",
"subject_sources": [
{"source": "creatures", "count": "1d4+1", "role": "defender"},
],
"latent_threat": False,
},
"The Social Event": {
"description": (
"Массовое или социальное действие существ. "
"Мирные варианты: процессия, ярмарка, состязание, представление, "
"глашатай зачитывает указ, свадьба гоблинов. "
"Тёмные варианты: культисты призывают демона через ритуал жертвоприношения, "
"кобольды пытаются оживить каменного идола, "
"друиды проводят обряд плодородия — прервать можно, но опасно."
),
"subject_sources": [
{"source": "npc", "count": "1d4+2", "role": "bystander"},
],
"latent_threat": False,
},
"The Internal Conflict": {
"description": "Две группы существ конфликтуют между собой: бунт, драка за добычу, политический протест/митинг, разборки между бандами, хищник - напавший на патруль.",
"subject_sources": [
{"source": "creatures", "count": "1d4", "role": "aggressor"},
{"source": "creatures", "count": "1d4", "role": "defender"},
],
"latent_threat": False,
},
"The Shadow": {
"description": "Существа скрытно наблюдают, крадутся следом, пытаются обокрасть кого-то или совершают тайное действие.",
"subject_sources": [
{"source": "creatures", "count": "1d3", "role": "primary"},
],
"latent_threat": False,
},
"The Interaction": {
"description": (
"Существа или NPC инициативно ищут контакта с героями. "
"Диапазон от мирного (торговцы, проводники, пьяный с байками) "
"до условного (медуза предлагает пропуск в обмен на историю, "
"хаг торгуется за услугу, дьявол — за душу, "
"культист вербует в орден)."
),
"subject_sources": [
{"source": "npc", "count": "1d3", "role": "primary"},
],
"latent_threat": False,
},
"The Chase": {
"description": (
"Динамичное преследование или гонка, требующие проверок навыков, "
"а не прямого боя. "
"Примеры: конная погоня за курьером с секретным письмом, "
"скачки на грифонах над городом, гонки на лодках по подземной реке, "
"беглый преступник петляет по рыночным рядам, "
"гарпии преследуют торговый корабль."
),
"subject_sources": [
{"source": "creatures", "count": "1d3", "role": "aggressor"},
{"source": "npc", "count": "1", "role": "victim"},
],
"latent_threat": False,
},
"The Challenge": {
"description": (
"Прямой вызов на состязание, дуэль или испытание навыков без цели убить. "
"Примеры: дворянин требует сатисфакции за оскорбление, варвар вызывает на "
"кулачный бой у костра, эльфийский следопыт предлагает пари на точность стрельбы, "
"монах вызывает на испытание воли (кто дольше простоит под ледяным водопадом)."
),
"subject_sources": [
{"source": "npc", "count": "1", "role": "aggressor"},
{"source": "npc", "count": "1d4", "role": "bystander", "optional": True},
],
"latent_threat": False,
},
},
"PvE": {
"The Sudden Crisis": {
"description": "Динамичная, срочная проблема среды (пожар, сбежавшая телега/животное, обвал, обрушение конструкции, землетрясение, тонущее судно, паника на рынке, т.п.).",
"subject_sources": [
{"source": "npc", "count": "1", "role": "victim", "optional": True},
],
"latent_threat": False,
},
"The Obstacle": {
"description": "Масштабная преграда, блокирующая путь (завал на лесной тропе, айсберг, заросли, пропасть, запертые ворота).",
"subject_sources": [],
"latent_threat": False,
},
"The Hazard": {
"description": "Пассивная опасная зона или ловушка (водоворот, гейзер, ядовитые споры, зыбучие пески).",
"subject_sources": [],
"latent_threat": True,
},
"The Anomaly": {
"description": "Сложное природное или магическое явление (мираж, корабль-призрак, зона дикой магии, парящие острова, светящиеся туманы, портал).",
"subject_sources": [],
"latent_threat": True,
},
"The Weather": {
"description": (
"Экстремальное погодное явление, меняющее условия всей зоны встречи. "
"Примеры: внезапная метель обнуляет видимость и замедляет движение, "
"пылевая буря на равнине, ураган у побережья, "
"кислотный дождь в Nine Hells разъедает снаряжение, "
"густой туман скрывает всё дальше 10 футов, "
"магическая гроза бьёт молниями случайно."
),
"subject_sources": [],
"latent_threat": True, # в буре может скрываться хищник
},
},
"PvX": {
"The Landmark": {
"description": (
"Заметный объект инфраструктуры, древности или магии. "
"Пассивные: пустые руины, статуя героя, монолит с надписями, "
"кораблекрушение, каменный круг. "
"Интерактивные (Puzzle): древняя дверь с загадкой на трёх языках, "
"магический замок с шифром из созвездий, "
"каменная плита с рунами, требующими правильного порядка нажатий, "
"зеркало, отражающее иное место."
),
"subject_sources": [],
"latent_threat": True,
},
"The Remnant": {
"description": (
"Свидетельства недавних или давних событий. "
"Краткосрочные следы: трупы, сломанные повозки, плавающие обломки, "
"остатки лагеря, брошенные вещи, сожжённая роща. "
"Долгосрочные последствия (Fallout): отравленная река после битвы армий, "
"выжженная земля проклятием личей, "
"зона некротической энергии — земля мертва уже десять лет, "
"пепелище деревни с ловушками мародёров."
),
"subject_sources": [],
"latent_threat": True,
},
"The Cache": {
"description": "Находка, имеющая ценность (магическое растение, утерянный груз, схрон, жемчуг, сундук с сокровищами, полезный предмет на тротуаре).",
"subject_sources": [],
"latent_threat": True,
},
"The Lair": {
"description": "Место обитания монстров, героев может ждать хозяин дома (грот осьминога, заселенные руины, гнездо грифона, бандитский притон, т.п.).",
"subject_sources": [
{"source": "creatures", "count": "1d6", "role": "primary"},
],
"latent_threat": False,
},
"The Omen": {
"description": (
"Информационный сигнал из среды — от неясного до конкретного. "
"Знамения: странная мелодия из тумана, дым трёх цветов на горизонте, "
"все птицы разом умолкли, мертвая рыба плывёт по реке вверх. "
"Сообщения (Message): послание в бутылке с зашифрованными координатами, "
"магический камень с записью голоса мёртвого мага, "
"надпись на стене древним языком («они уже внутри»), "
"застывший в янтаре свиток с предупреждением."
),
"subject_sources": [],
"latent_threat": True,
},
},
"PvSelf": {
"The Plea": {
"description": (
"Кто-то молит о помощи или ситуация требует самопожертвования. "
"Просьба о действии: выживший на обломках судна, "
"раненый рыцарь у дороги, пленник в клетке, "
"умоляющий купец («они убьют мою семью»). "
"Просьба об отказе (Sacrifice): сломай магический меч — снимешь проклятие с деревни, "
"сожги эти записи — они единственное доказательство твоей невиновности, "
"потеряй память о прошлом, чтобы пройти через врата богов."
),
"subject_sources": [
{"source": "npc", "count": "1", "role": "victim"},
],
"latent_threat": False,
},
"The Moral Crossroads": {
"description": "Столкновение с явной несправедливостью или злоупотреблением (задиры, произвол стражи или налоговика, несправедливый арест, жестокое обращение с животными, лорд приказывает выдать беглого слугу, т.п.).",
"subject_sources": [
{"source": "npc", "count": "1d3+", "role": "aggressor"},
{"source": "npc", "count": "1", "role": "victim"},
],
"latent_threat": False,
},
"The Temptation": {
"description": "Соблазн получить выгоду сомнительным путем (теневая сделка, бесхозный кошелёк или груз на дороге, возможность украсть у пьяного, забытые в храме деньги, т.п.).",
"subject_sources": [
{"source": "npc", "count": "1", "role": "primary", "optional": True},
],
"latent_threat": False,
},
"The Betrayal": {
"description": (
"Герой обнаруживает, что союзник, друг или заказчик предал его. "
"Примеры: контрактник сбежал с авансом и уликами на героев, "
"NPC которого спасли — оказывается осведомителем врага, "
"заказчик нанимал, зная что посылает на верную смерть, "
"проводник ведёт прямо в засаду, "
"союзный маг тайно работает на гильдию убийц."
),
"subject_sources": [
{"source": "npc", "count": "1", "role": "traitor"},
],
"latent_threat": False,
},
},
}

View File

@ -0,0 +1,174 @@
DATA = {
"arctic": {
"1-4":
[(1, 1, "1 giant owl"), (2, 5, "1d6 + 3 kobolds"),
(6, 8, "1d4 + 3 trappers (commoners)"), (9, 10, "1 owl"),
(11, 12, "2d4 blood hawks"), (13, 17, "2d6 bandits"),
(18, 20, "1d3 winged kobolds with 1d6 kobolds"),
(21, 25,
"The partially eaten carcass of a mammoth, from which 1d4 weeks of rations can be harvested."
), (26, 29, "2d8 hunters tribal warriors"), (30, 35, "1 half-ogre"),
(36, 40, "Single-file tracks in the snow that stop abruptly"),
(41, 45, "1d3 ice mephits"), (46, 50, "1 brown bear"),
(51, 53, "1d6 + 1 orcs"), (54, 55, "1 polar bear"),
(56, 57, "1d6 scouts"), (58, 60, "1 saber-toothed tiger"),
(61, 65,
"A frozen pond with a jagged hole in the ice that appears recently made"
), (66, 68, "1 berserker"), (69, 70, "1 ogre"),
(71, 72, "1 griffon"), (73, 75, "1 druid"),
(76, 80, "3d4 refugees (commoners) fleeing from orcs"),
(81, 81, "1d3 veterans"), (82, 82, "1d4 orogs"),
(83, 83, "2 brown bears"),
(84, 84, "1 orc Eye of Gruumsh and 2d8 orcs"),
(85, 85, "1d3 winter wolves"), (86, 87, "1d4 yetis"),
(88, 88, "1 half-ogre"), (89, 89, "1d3 manticores"),
(90, 90, "1 bandit captain with 2d6 bandits"), (91, 91, "1 revenant"),
(92, 93, "1 troll"), (94, 95, "1 werebear"),
(96, 97, "1 young remorhaz"), (98, 98, "1 mammoth"),
(99, 99, "1 young white dragon"), (100, 100, "1 frost giant")],
"5-10":
[(1, 5, "2 saber-toothed tigers"), (6, 7, "1d4 half-ogres"),
(8, 10, "1d3 + 1 brown bears"), (11, 15, "1d3 polar bears"),
(16, 20, "2d4 berserkers"),
(21, 25,
"A half-orc druid tending to an injured polar bear. If the characters assist the druid, she gives them a vial of antitoxin."
), (26, 30, "2d8 scouts"), (31, 35, "2d4 ice mephits"),
(36, 40,
"2d6 + 1 zombies aboard a galleon trapped in the ice. Searching the ship yields 2d20 days of rations."
), (41, 45, "1 manticore"), (46, 50, "2d6 + 3 orcs"),
(51, 53, "1d6 + 2 ogres"), (54, 55, "2d4 griffons"),
(56, 57, "1d4 veterans"),
(58, 60,
"1 bandit captain with 1 druid, 1d3 berserkers, and 2d10 + 5 bandits"
), (61, 65, "1d4 hours of extreme cold"),
(66, 68, "1 young remorhaz"),
(69, 72, "1 orc Eye of Gruumsh with 1d6 orogs and 2d8 + 6 orcs"),
(73, 75, "1 revenant"),
(76, 80, "A howl that echoes over the land for 1d3 minutes"),
(81, 82, "1d3 mammoths"), (83, 84, "1 young white dragon"),
(85, 86, "2d4 winter wolves"), (87, 88, "1d6 + 2 yetis"),
(89, 90, "1d2 frost giants"), (91, 92, "1d3 werebears"),
(93, 94, "1d4 trolls"), (95, 96, "1 abominable yeti"),
(97, 98, "1 remorhaz"), (99, 99, "1 roc"),
(100, 100, "2d4 young remorhazes")],
"11-16":
[(1, 1, "1 abominable yeti"), (2, 4, "1d6 revenants"),
(5, 10, "1d4 + 1 werebears"), (11, 20, "1d3 young white dragons"),
(21, 25,
"A blizzard that reduces visibility to 5 feet for 1d6 hours"),
(26, 35, "1 roc"),
(36, 40,
"A herd of 3d20 + 60 caribou (deer) moving through the snow"),
(41, 50, "1d4 mammoths"), (51, 60, "1d8 + 1 trolls"),
(61, 65,
"A mile-wide frozen lake in which preserved corpses of strange creatures can be seen"
), (66, 75, "2d4 young remorhazes"),
(76, 80,
"A crumbling ice castle littered with the frozen bodies of blue-skinned humanoids"
), (81, 90, "1 adult white dragon"),
(91, 96, "1d8 + 1 frost giants"), (97, 99, "1d4 remorhazes"),
(100, 100, "1 ancient white dragon")],
"17-20":
[(1, 2, "2d10 revenants"), (3, 4, "2d8 trolls"),
(5, 6, "2d10 werebears"), (7, 8, "1 frost giant"),
(9, 10, "2d4 young remorhazes"), (11, 20, "1d4 frost giants"),
(21, 25,
"A Circular patch of black ice. Warmer air, machinery frozen within."
), (26, 35, "1 ancient white dragon"),
(36, 40, "An adventurer frozen; 50% chance rare magic item"),
(41, 50, "1d3 abominable yetis"), (51, 60, "1d4 remorhazes"),
(61, 65, "A 500-foot-high wall of ice, 300 feet thick"),
(66, 75, "1d4 rocs"),
(76, 80, "The likeness of a stern woman carved into a mountain"),
(81, 90, "1d10 frost giants with 2d4 polar bears"),
(91, 96, "1d3 adult white dragons"), (97, 99, "2d4 abominable yetis"),
(100, 100, "1 ancient white dragon with 1d3 young white dragons")]
},
"coastal": {
"1-4":
[(1, 1, "1 pseudodragon"), (2, 5, "2d8 crabs"),
(6, 10, "2d6 fishers (commoners)"), (11, 11, "1d3 poisonous snakes"),
(12, 13, "1d6 guards protecting a standard noble"),
(14, 15, "2d4 scouts"), (16, 18, "2d10 merfolk"),
(19, 20, "1d6 + 2 sahuagin"),
(21, 25,
"1d4 ghouls feeding on corpses. Search: 2d6 ruined silk, 50ft rope, salted herring."
), (26, 27, "1d4 winged kobolds with 1d6 + 1 kobolds"),
(28, 29, "2d6 tribal warriors"), (30, 31, "3d4 kobolds"),
(32, 33, "2d4 + 5 blood hawks"), (34, 35, "1d8 + 1 pteranodons"),
(36, 40,
"A few dozen baby turtles struggling to make their way to the sea"),
(41, 42, "1d6 + 2 giant lizards"), (43, 44, "1d6 + 4 giant crabs"),
(45, 46, "2d4 stirges"), (47, 48, "2d6 + 3 bandits"),
(49, 53, "2d4 sahuagin"), (54, 55, "1d6 + 2 scouts"),
(56, 60, "1 sea hag"),
(61, 65,
"A momentary formation in the waves that looks like an enormous face"
), (66, 70, "1 druid"), (71, 75, "1d4 harpies"),
(76, 80, "A lone hermit (acolyte) sitting on the beach"),
(81, 81, "1d4 berserkers"), (82, 82, "1d6 giant eagles"),
(83, 83, "2d4 giant toads"), (84, 84, "1d4 ogres or 1d4 merrow"),
(85, 85, "3d6 sahuagin"), (86, 86, "1d4 veterans"),
(87, 87, "1d2 plesiosauruses"),
(88, 88, "1 bandit captain with 2d6 bandits"),
(89, 89, "1d3 manticores"), (90, 90, "1 banshee"),
(91, 92, "1d4 + 3 griffons"),
(93, 94, "1 sahuagin priestess with 1d3 merrow and 2d6 sahuagin"),
(95, 96, "1 sahuagin baron"), (97, 98, "1 water elemental"),
(99, 99, "1 cyclops"), (100, 100, "1 young bronze dragon")],
"5-10":
[(1, 1, "2d8 giant wolf spiders"), (2, 3, "3d6 pteranodons"),
(4, 5, "2d4 scouts"), (6, 7, "1d6 + 2 sahuagin"), (8, 8, "1 sea hag"),
(9, 10, "1d4 + 1 giant toads"), (11, 15, "3d6 sahuagin"),
(16, 20, "2d6 giant eagles"),
(21, 25, "A pseudodragon chasing gulls"), (26, 29, "1d2 druids"),
(30, 32, "2d4 + 1 giant toads"),
(33, 35, "1 commoner singing (day) or 1 banshee (night)"),
(36, 40, "A stoppered bottle with illegible note"),
(41, 43, "3 sea hags"), (44, 46, "1d8 + 1 harpies"),
(47, 50, "1d4 plesiosauruses"), (51, 53, "1d4 manticores"),
(54, 56, "2d4 ogres"), (57, 60, "1d10 griffons"),
(61, 65, "A battle at sea between two galleons"),
(66, 70, "1d4 + 3 merrow"),
(71, 75,
"Pirate crew: 1 bandit captain, 1 druid, 2 berserkers, 2d12 bandits"
), (76, 80, "A severed humanoid hand tangled in a net"),
(81, 82, "1 water elemental"), (83, 84, "1 cyclops"),
(85, 86, "1d4 banshees (night)"), (87, 88, "2d4 veterans"),
(89, 90, "1 young bronze dragon"), (91, 93, "1d3 cyclopes"),
(94, 95, "1 young blue dragon"),
(96, 96,
"1 sahuagin baron with 1d3 sahuagin priestesses and 2d8 sahuagin"),
(97, 97, "1 djinni"), (98, 98, "1 roc"), (99, 99, "1 marid"),
(100, 100, "1 storm giant")],
"11-16":
[(1, 1, "1d4 banshees (night)"), (2, 4, "1 cyclops"),
(5, 8, "1d6 + 2 manticores"), (9, 10, "1d8 + 2 veterans"),
(11, 20, "1 young blue dragon"), (21, 25, "1d6 dragon turtle eggs"),
(26, 35, "1d4 sahuagin barons"),
(36, 40, "A trident partially buried in the sand"),
(41, 50, "1 young bronze dragon"), (51, 55, "1 marid"),
(56, 60, "1d6 water elementals"),
(61, 65, "2d6 ghasts crawling over wrecked ships"),
(66, 70, "1 djinni"), (71, 75, "1d3 young bronze dragons"),
(76, 80, "A beached whale (explodes if damaged, DC 15 Dex, 5d6 dmg)"),
(81, 82, "2d4 cyclopes"), (83, 84, "1 storm giant"),
(85, 86, "1d3 young blue dragons"), (87, 88, "1 adult bronze dragon"),
(89, 90, "1 adult blue dragon"), (91, 93, "1d3 rocs"),
(94, 97, "1 dragon turtle"), (98, 99, "1 ancient bronze dragon"),
(100, 100, "1 ancient blue dragon")],
"17-20": [(1, 10, "1 roc"), (11, 20, "1 storm giant"),
(21, 25,
"An adult bronze dragon fighting an adult blue dragon"),
(26, 40, "2d4 cyclopes"),
(41, 50, "1 adult bronze dragon or 1 adult blue dragon"),
(51, 60, "1d3 djinni or 1d3 marids"),
(61, 70, "1 dragon turtle"), (71, 75, "1d3 rocs"),
(76, 80, "1d6 + 2 waterspouts dancing on water"),
(81, 90, "1d6 young blue dragons"),
(91, 96, "1 ancient bronze dragon"),
(97, 99, "1 ancient blue dragon"),
(100, 100, "1d3 + 1 storm giants")]
},
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,92 @@
Для интеграции механик **Surprise Roll** и **Reaction** из ACKS2 в ваш скрипт на Python, необходимо внести следующие изменения:
### 1. Как реализовать Surprise Roll (1d6)
Вместо автоматической проверки Скрытности против Пассивного Восприятия (D&D 5e), внедрите логику матрицы:
* **Параметры:** На входе функции теперь нужны два булевых флага для каждой стороны: `has_foreknowledge` (знали ли о присутствии заранее) и `has_los` (видят ли противника в момент начала).
* **Матрица:** Создайте словарь или цепочку `if-elif`, которая возвращает одно из состояний: `No Surprise`, `Roll Surprise (+1/0/-1)` или `No Encounter`.
* **Модификаторы:**
* Создайте аргумент `situational_modifiers` (целое число).
* **Классовые/Расовые:** Если в группе есть «Следопыт» (аналог Explorer), добавляйте +1 к его броску.
* **Скрытность:** Если одна сторона использует скрытность (Hiding/Sneaking), она накладывает штраф **-2** на бросок сюрприза оппонента.
* **Логика:** Бросок `1d6 + бонусы - штрафы`. Если результат `<= 2`, персонаж пропускает первый раунд (Surprised).
### 2. Как реализовать Reaction Roll (2d6)
Эта механика заменяет мгновенную инициативу в неочевидных ситуациях:
* **Бросок:** `2d6 + Харизма лидера (Face)`.
* **Модификатор Face:** Выбирайте персонажа с наивысшей Харизмой. Добавьте его модификатор к броску.
* **Ситуативные модификаторы:** Введите параметр `reaction_mod`. Например, если игроки достали оружие, добавьте `-2` (к враждебности), если предложили золото (взятка) — согласно таблице ACKS2 (+1/+2/+3 в зависимости от суммы).
* **Результат:** Верните текстовое описание (Hostile, Neutral, Friendly и т.д.), которое определит поведение монстров до начала боя.
### 3. Учет ситуативных модификаторов
Чтобы они подавались «явно для конкретного столкновения», расширьте структуру `EncounterRequest`.
Вместо того чтобы высчитывать их внутри скрипта, передавайте их как:
* `surprise_mod_bonus`: (например, +1 за бдительность).
* `surprise_mod_penalty`: (например, -2, если враги — затаившиеся варвары).
* `reaction_situational_mod`: (например, -4, если игроки осквернили алтарь этого племени ранее).
### Пример структуры данных для функции:
Вместо простого `level` и `environment`, объект `req` должен принимать:
```python
{
"environment": "underdark",
"level": 5,
"foreknowledge_party": True,
"foreknowledge_monsters": False,
"los_party": True,
"los_monsters": True,
"surprise_penalty": -2, # За скрытность монстров
"reaction_bonus": 2 # Модификатор Харизмы "лица" группы
}
```
Это позволит скрипту не просто выкидывать монстра, а сразу генерировать контекст: «Вы заметили их первыми (Surprise Roll монстров с -2), они настроены Неуверенно (Reaction: 7)».
---
Для внедрения механик из ACKS II (Внезапность и Реакция) в ваш генератор случайных встреч, нам потребуется расширить логику обработки каждого запроса. Поскольку генератор возвращает Markdown-текст, результаты проверок на внезапность и реакцию отлично впишутся как дополнительные пункты в описание каждой сгенерированной встречи.
Чтобы учесть ситуативные модификаторы, вам нужно расширить словарь запроса (тот, что сейчас принимает только биом и уровень). Ситуативные данные должны передаваться из того места, где формируется запрос к скрипту.
Вот как это можно архитектурно выстроить и какие варианты реализации существуют.
### Как передавать ситуативные модификаторы
В параметры запроса (в словарь) необходимо добавить опциональный блок контекста. Например, ключ `context`, внутри которого могут быть:
1. **Для Внезапности:** логические флаги (да/нет) для `party_foreknowledge`, `party_los`, `monster_foreknowledge`, `monster_los`. Также сюда можно передавать численные модификаторы: `party_surprise_mod` (например, если в группе есть варвар или исследователь) и `monster_surprise_mod` (чтобы учесть скрытность монстров).
2. **Для Реакции:** численное значение `face_cha_modifier` (модификатор Харизмы «лица» группы) и логический флаг `auto_hostile` (на случай, если группа выбила дверь или монстр — неразумный голодный хищник, где бросок реакции по правилам ACKS не нужен).
### Вариант 1: Жесткая симуляция (Передача полного контекста)
Этот вариант предполагает, что мастер или интерфейс тула точно знают, при каких обстоятельствах происходит встреча, и передают все параметры в скрипт.
* **Механика Внезапности:** Скрипт анализирует флаги предзнания (Foreknowledge) и видимости (LOS) для обеих сторон. На основе вашей матрицы из ACKS скрипт определяет результат (Кто не застигнут врасплох, а кому нужно бросать кубик). Если требуется бросок, скрипт кидает 1d6, применяет переданные штрафы/бонусы (от скрытности, классов или освещения) и выдает финальный статус: «Внимательны» или «Застигнуты врасплох». Также скрипт может вывести статус возможности уклонения (evasion).
* **Механика Реакции:** Скрипт проверяет флаг `auto_hostile`. Если он активен (например, стычка с нежитью), скрипт сразу пишет «Настроение: Враждебное (Атакуют)». Если нет, скрипт бросает 2d6, прибавляет переданный модификатор Харизмы группы (и бонусы за взятки, если они переданы в контексте), а затем сравнивает результат со шкалой от 2 до 12, выводя отношение от «Враждебного» до «Дружелюбного».
**Плюсы:** Максимальная точность по правилам ACKS.
**Минусы:** Требует передачи большого количества данных в каждом запросе.
### Вариант 2: Генеративная симуляция (Рандомизация контекста)
Поскольку это генератор *случайных* встреч, контекст часто неизвестен до момента броска. В этом подходе скрипт сам придумывает обстоятельства встречи.
* **Внезапность:** Скрипт случайным образом определяет, кто кого заметил первым. Например, можно привязать это к биому (в лесу шанс заметить друг друга ниже, чем на равнине). Скрипт сам бросает 1d6 за монстров и игроков, генерируя текст вроде: *"Группа заметила монстров издалека, монстры застигнуты врасплох (бросок 1d6: 2)"*.
* **Реакция:** Скрипт автоматически бросает 2d6. В Markdown-отчет выводится базовая реакция монстров *до* влияния Харизмы игроков. Вы можете написать: *"Базовая реакция: 7 (Нейтрально). Мастер, прибавьте модификатор Харизмы лидера для итогового результата"*.
**Плюсы:** Скрипт работает автономно, генерируя готовые мини-сценки, не требуя сложной настройки от пользователя.
**Минусы:** Мастеру придется на лету адаптировать сгенерированный скриптом контекст под текущую игру.
### Вариант 3: Гибридный подход (Рекомендуемый)
Скрипт проверяет параметры запроса. Если пользователь не передал конкретных модификаторов и контекстов, применяются значения по умолчанию или легкий рандом.
**Логика работы скрипта будет такой:**
1. Генерируется сам монстр/встреча (базовая часть вашего скрипта).
2. Скрипт смотрит в запрос. Есть ли там флаги ситуаций?
3. Если флагов скрытности и видимости нет, скрипт предполагает «Стандартную встречу встречными курсами» (у обеих сторон есть Line of Sight, ни у кого нет Foreknowledge). Бросается 1d6 за обе группы и выводится результат Внезапности.
4. Высчитывается бросок Реакции 2d6. Если в запросе передано `face_cha_modifier`, он плюсуется на этапе скрипта, и выводится финальный текст (например, «Итог: 10 — Индифферентны»). Если не передано, выводится «Сырой бросок реакции: 8, измените в зависимости от модификаторов партии».
5. К описанию встречи в Markdown добавляются две новые строки: **Состояние перед боем (Внезапность)** и **Первичная реакция**.
**Для реализации без изменения архитектуры базы данных монстров:**
Всю математику и броски кубиков (1d6 и 2d6) нужно вынести в отдельные вспомогательные функции внутри вашего Python-файла. Эти функции будут принимать словарь с любыми ситуативными модификаторами, обрабатывать матрицу исходов (как серию if/else условий на основе флагов) и возвращать отформатированные строки, которые затем будут приклеиваться к переменной `res` перед добавлением в общий список результатов.
---
комментарий к Вариант 3: Гибридный подход (Рекомендуемый), который я выбираю
Ок, реализуй, только учти ##Tool Generator (правила формирования тула), также промпт генерации энкаунтера дополни. также, важно, чтобы агент по возможности не спрашивал доп контекста, а исходил из имеющейся информации. возможно, надо подавать на вход три модификатора, для монстров surprise и для партии surprise, и модификатор для взаимодействия реакции, каждый модификатор -с словарь пар строковое значение причина модификатора - целочисленное число, все модификаторы суммируются и дают результат итоговый. что скажешь? также, для броска, для результата при выводе с обработкой промпта и результата скрипта должна агентом прикладыватьться простые таблицы, предварителньо будет сказано, какие модификаторы использованы, какой реузльтата, и елси гм использует другие модификаторы, тот вот уже итоговое значение, его поменяешь , и вот тебе таблицы, что понять, что будет после изменения. Что скажешь?

96
generate_npc/abilities.js Normal file
View File

@ -0,0 +1,96 @@
// Приоритеты характеристик для каждого класса
const classStatPriority = {
Barbarian: ["Strength", "Constitution", "Dexterity", "Wisdom", "Charisma", "Intelligence"],
Bard: ["Charisma", "Dexterity", "Intelligence", "Constitution", "Wisdom", "Strength"],
Cleric: ["Wisdom", "Strength", "Dexterity", "Constitution", "Charisma", "Intelligence"], // Приоритет будет скорректирован в особых случаях
Druid: ["Wisdom", "Dexterity", "Constitution", "Intelligence", "Charisma", "Strength"],
Fighter: ["Strength", "Constitution", "Dexterity", "Wisdom", "Charisma", "Intelligence"], // Приоритет будет скорректирован в особых случаях
Monk: ["Wisdom", "Dexterity", "Constitution", "Charisma", "Intelligence", "Strength"],
Paladin: ["Strength", "Constitution", "Charisma", "Wisdom", "Dexterity", "Intelligence"],
Ranger: ["Dexterity", "Wisdom", "Constitution", "Strength", "Charisma", "Intelligence"],
Rogue: ["Dexterity", "Charisma", "Intelligence", "Constitution", "Wisdom", "Strength"],
Sorcerer: ["Charisma", "Constitution", "Dexterity", "Wisdom", "Intelligence", "Strength"],
Warlock: ["Charisma", "Dexterity", "Constitution", "Wisdom", "Intelligence", "Strength"],
Wizard: ["Intelligence", "Wisdom", "Constitution", "Dexterity", "Charisma", "Strength"],
};
// Функция для броска 3d6
function roll3d6() {
return Math.floor(Math.random() * 6 + 1) + Math.floor(Math.random() * 6 + 1) + Math.floor(Math.random() * 6 + 1);
}
// Функция для расчета модификатора атрибута
function getModifier(score) {
return Math.floor((score - 10) / 2);
}
// Функция для получения суммы модификаторов всех атрибутов
function calculateTotalModifiers(abilityScores) {
return abilityScores.reduce((total, score) => total + getModifier(score), 0);
}
function getAbilityScores(characterClass) {
let rolledStats = [];
let totalModifiers = 0;
// Продолжаем перебрасывать, пока сумма модификаторов не будет >= 0
do {
// Броски для атрибутов
rolledStats = [
roll3d6(),
roll3d6(),
roll3d6(),
roll3d6(),
roll3d6(),
roll3d6(),
];
// Считаем сумму модификаторов
totalModifiers = calculateTotalModifiers(rolledStats);
} while (totalModifiers < 0); // Если сумма модификаторов меньше 0, перебрасываем
// Сортируем броски по убыванию
rolledStats.sort((a, b) => b - a);
// Получаем приоритеты для выбранного класса //и клонируем
let priority = classStatPriority[characterClass].slice();
// Создаем объект для атрибутов
let characterStats = {};
// Копируем массив бросков
let stats = rolledStats.slice();
// Блок обработки особых случаев
if (characterClass === "Cleric" || characterClass === "Fighter") {
// Сила и Ловкость имеют одинаковый приоритет, но сила в массивах выше по допущению больше
let strIndex = priority.indexOf("Strength");
let dexIndex = priority.indexOf("Dexterity");
// Если позиции разные, решаем случайно
if (strIndex !== dexIndex) {
// Случайно решаем, кому достанется более высокий стат
if (Math.random() < 0.5) {
// Сила получает более высокий приоритет
priority[strIndex] = "Strength";
priority[dexIndex] = "Dexterity";
} else {
// Ловкость получает более высокий приоритет
priority[strIndex] = "Dexterity";
priority[dexIndex] = "Strength";
}
}
}
// Распределяем характеристики
for (let i = 0; i < priority.length; i++) {
characterStats[priority[i]] = stats[i];
}
return characterStats;
}
dv.abilities = {
getAbilityScores
}

61
generate_npc/abilities.py Normal file
View File

@ -0,0 +1,61 @@
# abilities.py
import random
CLASS_STAT_PRIORITY = {
"Barbarian": ["Strength", "Constitution", "Dexterity", "Wisdom", "Charisma", "Intelligence"],
"Bard": ["Charisma", "Dexterity", "Intelligence", "Constitution", "Wisdom", "Strength"],
"Cleric": ["Wisdom", "Strength", "Dexterity", "Constitution", "Charisma", "Intelligence"], # Приоритет будет скорректирован в особых случаях
"Druid": ["Wisdom", "Dexterity", "Constitution", "Intelligence", "Charisma", "Strength"],
"Fighter": ["Strength", "Constitution", "Dexterity", "Wisdom", "Charisma", "Intelligence"], # Приоритет будет скорректирован в особых случаях
"Monk": ["Wisdom", "Dexterity", "Constitution", "Charisma", "Intelligence", "Strength"],
"Paladin": ["Strength", "Constitution", "Charisma", "Wisdom", "Dexterity", "Intelligence"],
"Ranger": ["Dexterity", "Wisdom", "Constitution", "Strength", "Charisma", "Intelligence"],
"Rogue": ["Dexterity", "Charisma", "Intelligence", "Constitution", "Wisdom", "Strength"],
"Sorcerer": ["Charisma", "Constitution", "Dexterity", "Wisdom", "Intelligence", "Strength"],
"Warlock": ["Charisma", "Dexterity", "Constitution", "Wisdom", "Intelligence", "Strength"],
"Wizard": ["Intelligence", "Wisdom", "Constitution", "Dexterity", "Charisma", "Strength"],
"Commoner": ["Strength", "Dexterity", "Constitution", "Intelligence", "Wisdom", "Charisma"],
}
def _roll_3d6() -> int:
return sum(random.randint(1, 6) for _ in range(3))
def _get_modifier(score: int) -> int:
return (score - 10) // 2
def _total_modifiers(scores: list[int]) -> int:
return sum(_get_modifier(s) for s in scores)
def get_ability_scores(character_class: str) -> dict[str, int]:
"""Бросает 3d6 × 6 до тех пор, пока сумма модификаторов ≥ 0,
затем распределяет значения по приоритету класса."""
while True:
rolled = [_roll_3d6() for _ in range(6)]
if _total_modifiers(rolled) >= 0:
break
rolled.sort(reverse=True)
# Commoner: полный рандом — характеристики раздаются случайно,
# без приоритета, чтобы не создавать искусственного перекоса
if character_class == "Commoner":
stats = ["Strength", "Dexterity", "Constitution",
"Intelligence", "Wisdom", "Charisma"]
random.shuffle(stats)
return {stat: rolled[i] for i, stat in enumerate(stats)}
priority = list(CLASS_STAT_PRIORITY[character_class]) # копия
# Особые случаи: Cleric / Fighter — Сила и Ловкость случайно меняются местами
if character_class in ("Cleric", "Fighter"):
str_idx = priority.index("Strength")
dex_idx = priority.index("Dexterity")
if str_idx != dex_idx and random.random() < 0.5:
priority[str_idx], priority[dex_idx] = priority[dex_idx], priority[str_idx]
return {stat: rolled[i] for i, stat in enumerate(priority)}

112
generate_npc/ages.js Normal file
View File

@ -0,0 +1,112 @@
let youthAgeModificators = function (abilityScores) {
abilityScores["Strength"] -= 2;
abilityScores["Intelligence"] -= 2;
abilityScores["Wisdom"] -= 2;
return abilityScores;
};
let adultAgeModificators = function (abilityScores) {
/* No adjustments for Adult */
return abilityScores;
};
let middleAgeModificators = function (abilityScores) {
abilityScores["Strength"] -= 2;
abilityScores["Dexterity"] -= 2;
abilityScores["Constitution"] -= 2;
return abilityScores;
};
let oldAgeModificators = function (abilityScores) {
abilityScores["Strength"] -= 4;
abilityScores["Dexterity"] -= 4;
abilityScores["Constitution"] -= 4;
abilityScores["Charisma"] -= 2;
return abilityScores;
};
let ancientAgeModificators = function (abilityScores) {
abilityScores["Strength"] -= 6;
abilityScores["Dexterity"] -= 6;
abilityScores["Constitution"] -= 6;
abilityScores["Charisma"] -= 4;
return abilityScores;
};
// Low Fantasy: реалистичный мир, где доля взрослых выше, а возрастные категории распределены ближе к реальности.
let ages = {
Youth: {
chance: 15.0,
explanation: "equivalent to human 13 17",
abilities_modificator: youthAgeModificators,
},
Adult: {
chance: 50.0,
explanation: "equivalent to human 18 35",
abilities_modificator: adultAgeModificators,
},
MiddleAged: {
chance: 20.0,
explanation: "equivalent to human 36 55",
abilities_modificator: middleAgeModificators,
},
Old: {
chance: 10.0,
explanation: "equivalent to human 56 75",
abilities_modificator: oldAgeModificators,
},
Ancient: {
chance: 5.0,
explanation: "equivalent to human 76 95",
abilities_modificator: ancientAgeModificators,
},
};
function getAgeAndModifyAbilities(abilityScores) {
// Клонируем словарь abilityScores, чтобы не изменять оригинал
let clonedAbilityScores = Object.assign({}, abilityScores);
// Получаем сумму шансов для нормализации
let totalChance = Object.values(ages).reduce(
(sum, { chance }) => sum + chance,
0
);
// Генерируем случайное значение от 0 до 100
let randomValue = Math.random() * 100;
let cumulativeChance = 0;
let selectedAgeGroup = null;
for (let [
ageGroup,
{ chance, explanation, abilities_modificator },
] of Object.entries(ages)) {
// Нормализуем шанс текущей возрастной категории
let normalizedChance = (chance / totalChance) * 100;
cumulativeChance += normalizedChance;
if (randomValue <= cumulativeChance) {
selectedAgeGroup = {
ageCategory: ageGroup,
ageExplanation: explanation,
abilities_modificator,
};
break;
}
}
if (!selectedAgeGroup) {
throw new Error("Age group selection failed.");
}
// Применяем модификаторы к клонированным способностям
let { ageCategory, ageExplanation, abilities_modificator } = selectedAgeGroup;
let agedAbilityScores = abilities_modificator(clonedAbilityScores);
return [ageCategory, ageExplanation, agedAbilityScores];
}
dv.ages = {
getAgeAndModifyAbilities,
};

55
generate_npc/ages.py Normal file
View File

@ -0,0 +1,55 @@
# ages.py
import random
# Каждый возраст: chance (%), explanation, модификатор способностей (dict с дельтами)
AGES = {
"Youth": {
"chance": 15.0,
"explanation": "equivalent to human 13 17",
"modifiers": {"Strength": -2, "Intelligence": -2, "Wisdom": -2},
},
"Adult": {
"chance": 50.0,
"explanation": "equivalent to human 18 35",
"modifiers": {},
},
"MiddleAged": {
"chance": 20.0,
"explanation": "equivalent to human 36 55",
"modifiers": {"Strength": -2, "Dexterity": -2, "Constitution": -2},
},
"Old": {
"chance": 10.0,
"explanation": "equivalent to human 56 75",
"modifiers": {"Strength": -4, "Dexterity": -4, "Constitution": -4, "Charisma": -2},
},
"Ancient": {
"chance": 5.0,
"explanation": "equivalent to human 76 95",
"modifiers": {"Strength": -6, "Dexterity": -6, "Constitution": -6, "Charisma": -4},
},
}
def get_age_and_modify_abilities(
ability_scores: dict[str, int],
) -> tuple[str, str, dict[str, int]]:
"""Выбирает возрастную категорию и применяет модификаторы к копии ability_scores."""
total = sum(v["chance"] for v in AGES.values())
r = random.random() * total
cumulative = 0.0
selected_key = None
for key, data in AGES.items():
cumulative += data["chance"]
if r <= cumulative:
selected_key = key
break
if selected_key is None:
selected_key = list(AGES.keys())[-1]
data = AGES[selected_key]
aged_scores = dict(ability_scores)
for stat, delta in data["modifiers"].items():
aged_scores[stat] = aged_scores.get(stat, 0) + delta
return selected_key, data["explanation"], aged_scores

1575
generate_npc/backgrounds.js Normal file

File diff suppressed because it is too large Load Diff

390
generate_npc/backgrounds.py Normal file
View File

@ -0,0 +1,390 @@
# backgrounds.py
import random
from typing import Optional
import skills as sk
import languages as lang
# ─── Инструменты ─────────────────────────────────────────────────────────────
alchemists_supplies = "Alchemist's supplies"
brewers_supplies = "Brewer's supplies"
calligraphers_supplies= "Calligrapher's supplies"
carpenters_tools = "Carpenter's tools"
cartographers_tools = "Cartographer's tools"
cobblers_tools = "Cobbler's tools"
cooks_utensils = "Cook's utensils"
glassblowers_tools = "Glassblower's tools"
jewelers_tools = "Jeweler's tools"
leatherworkers_tools = "Leatherworker's tools"
masons_tools = "Mason's tools"
painters_tools = "Painter's tools"
potters_tools = "Potter's tools"
smiths_tools = "Smith's tools"
tinkers_tools = "Tinker's tools"
weavers_tools = "Weaver's tools"
woodcarvers_tools = "Woodcarver's tools"
barber_tools = "Barber's Tools" # Добавил (хоумбрю)
artisans_tools = [
alchemists_supplies, brewers_supplies, calligraphers_supplies, carpenters_tools,
cartographers_tools, cobblers_tools, cooks_utensils, glassblowers_tools,
jewelers_tools, leatherworkers_tools, masons_tools, painters_tools,
potters_tools, smiths_tools, tinkers_tools, weavers_tools, woodcarvers_tools,
]
dice_set = "Dice set"
dragonchess_set = "Dragonchess set"
playing_card_set = "Playing card set"
three_dragon_ante= "Three-Dragon Ante set"
gaming_sets = [dice_set, dragonchess_set, playing_card_set, three_dragon_ante]
disguise_kit = "Disguise kit"
forgery_kit = "Forgery kit"
herbalism_kit = "Herbalism kit"
navigators_tools = "Navigator's tools"
poisoners_kit = "Poisoner's kit"
thieves_tools = "Thieves' tools"
vehicles_land = "Vehicles (Land)"
vehicles_water = "Vehicles (Water)"
bagpipes = "Bagpipes"
drum = "Drum"
dulcimer = "Dulcimer"
flute = "Flute"
lute = "Lute"
lyre = "Lyre"
horn = "Horn"
pan_flute = "Pan flute"
shawm = "Shawm"
viol = "Viol"
musical_instruments = [bagpipes, drum, dulcimer, flute, lute, lyre, horn, pan_flute, shawm, viol]
all_tools = artisans_tools + gaming_sets + musical_instruments + [
disguise_kit, forgery_kit, herbalism_kit, navigators_tools,
poisoners_kit, thieves_tools, vehicles_land, vehicles_water,
]
# Сокращения для языков
_living_exc = lang.living_languages_except_common
_dead = lang.dead_languages
# ─── Группы профессий ────────────────────────────────────────────────────────
maritime_professions = {
"Моряк": {"skills":[sk.athletics, sk.perception], "tools":[navigators_tools], "languages":[_living_exc], "chance":35},
"Рыбак": {"skills":[sk.survival, sk.animal_handling],"tools":["Fishing tackle"], "languages":[_living_exc], "chance":25},
"Пират": {"skills":[sk.athletics, sk.perception], "tools":[vehicles_water, navigators_tools],"languages":[], "chance":10},
"Гребец": {"skills":[sk.athletics, sk.survival], "tools":[vehicles_water], "languages":[_living_exc], "chance":10},
"Боцман": {"skills":[sk.intimidation, sk.athletics], "tools":[vehicles_water, navigators_tools],"languages":[_living_exc], "chance":5},
"Штурман": {"skills":[sk.arcana, sk.perception], "tools":[navigators_tools, cartographers_tools],"languages":[_living_exc],"chance":3},
"Канонир": {"skills":[sk.athletics, sk.investigation],"tools":[smiths_tools], "languages":[_living_exc], "chance":3},
"Кок корабля": {"skills":[sk.insight, sk.performance], "tools":[cooks_utensils], "languages":[_living_exc], "chance":3},
"Кладовщик корабля": {"skills":[sk.investigation, sk.perception],"tools":[vehicles_water], "languages":[_living_exc], "chance":2},
"Лоцман": {"skills":[sk.perception, sk.survival], "tools":[navigators_tools], "languages":[_living_exc], "chance":2},
"Канатчик": {"skills":[sk.athletics, sk.sleight_of_hand],"tools":[weavers_tools], "languages":[_living_exc], "chance":1},
"Оружейник корабля": {"skills":[sk.athletics, sk.investigation],"tools":[smiths_tools], "languages":[_living_exc], "chance":1},
}
agrarian_professions = {
"Фермер": {"skills":[sk.animal_handling, sk.nature], "tools":[brewers_supplies, carpenters_tools, cobblers_tools, cooks_utensils, leatherworkers_tools, masons_tools], "languages":[_living_exc], "chance":25},
"Пастух": {"skills":[sk.animal_handling, sk.nature], "tools":[musical_instruments], "languages":[_living_exc], "chance":15},
"Земледелец": {"skills":[sk.athletics, sk.nature], "tools":[], "languages":[_living_exc], "chance":10},
"Охотник": {"skills":[sk.stealth, sk.survival], "tools":["Animal traps"], "languages":[_living_exc], "chance":10},
"Овчар": {"skills":[sk.animal_handling, sk.survival],"tools":[barber_tools], "languages":[_living_exc], "chance":5},
"Скотник": {"skills":[sk.animal_handling, sk.survival],"tools":[barber_tools, leatherworkers_tools], "languages":[_living_exc], "chance":5},
"Свинопас": {"skills":[sk.animal_handling, sk.nature], "tools":[musical_instruments], "languages":[_living_exc], "chance":5},
"Мельник": {"skills":[sk.athletics, sk.investigation],"tools":[cooks_utensils], "languages":[_living_exc], "chance":4},
"Птицевод": {"skills":[sk.animal_handling, sk.survival],"tools":[woodcarvers_tools], "languages":[_living_exc], "chance":3},
"Заводчик лошадей": {"skills":[sk.animal_handling, sk.persuasion],"tools":[musical_instruments, calligraphers_supplies], "languages":[_living_exc], "chance":3},
"Садовник": {"skills":[sk.nature, sk.survival], "tools":[herbalism_kit], "languages":[_living_exc], "chance":3},
"Пчеловод": {"skills":[sk.animal_handling, sk.survival],"tools":[woodcarvers_tools, cooks_utensils], "languages":[_living_exc], "chance":2},
"Сыровар": {"skills":[sk.animal_handling, sk.investigation],"tools":[cooks_utensils], "languages":[_living_exc], "chance":2},
"Винодел": {"skills":[sk.nature, sk.persuasion], "tools":[brewers_supplies], "languages":[_living_exc], "chance":2},
"Молочник": {"skills":[sk.animal_handling, sk.athletics],"tools":[cooks_utensils], "languages":[_living_exc], "chance":2},
"Зверолов": {"skills":[sk.stealth, sk.survival], "tools":["Animal traps"], "languages":[_living_exc], "chance":2},
}
artisan_professions = {
"Кузнец": {"skills":[sk.athletics, sk.insight], "tools":[smiths_tools], "languages":[_living_exc], "chance":12},
"Кожевенник": {"skills":[sk.survival, sk.investigation], "tools":[leatherworkers_tools],"languages":[_living_exc], "chance":9},
"Ткач": {"skills":[sk.insight, sk.investigation], "tools":[weavers_tools], "languages":[_living_exc], "chance":9},
"Пекарь": {"skills":[sk.insight, sk.investigation], "tools":[cooks_utensils], "languages":[_living_exc], "chance":8},
"Мясник": {"skills":[sk.athletics, sk.survival], "tools":[cooks_utensils], "languages":[_living_exc], "chance":8},
"Сапожник": {"skills":[sk.investigation, sk.perception], "tools":[cobblers_tools], "languages":[_living_exc], "chance":6},
"Повар": {"skills":[sk.insight, sk.performance], "tools":[cooks_utensils], "languages":[_living_exc], "chance":7},
"Столяр": {"skills":[sk.athletics, sk.perception], "tools":[carpenters_tools], "languages":[_living_exc], "chance":5},
"Резчик по дереву": {"skills":[sk.athletics, sk.perception], "tools":[woodcarvers_tools], "languages":[_living_exc], "chance":5},
"Каменотес": {"skills":[sk.athletics, sk.investigation], "tools":[masons_tools], "languages":[_living_exc], "chance":5},
"Гончар": {"skills":[sk.investigation, sk.performance],"tools":[potters_tools], "languages":[_living_exc], "chance":5},
"Булочник": {"skills":[sk.insight, sk.performance], "tools":[cooks_utensils], "languages":[_living_exc], "chance":4},
"Бондарь": {"skills":[sk.athletics, sk.investigation], "tools":[woodcarvers_tools], "languages":[_living_exc], "chance":4},
"Пивовар": {"skills":[sk.investigation, sk.insight], "tools":[brewers_supplies], "languages":[_living_exc], "chance":4},
"Кирпичник": {"skills":[sk.athletics, sk.investigation], "tools":[masons_tools], "languages":[_living_exc], "chance":3},
"Кондитер": {"skills":[sk.insight, sk.investigation], "tools":[cooks_utensils], "languages":[_living_exc], "chance":2},
"Изготовитель свечей":{"skills":[sk.investigation, sk.insight], "tools":[cooks_utensils], "languages":[_living_exc], "chance":2},
"Механик": {"skills":[sk.investigation, sk.sleight_of_hand],"tools":[tinkers_tools], "languages":[_living_exc], "chance":2},
"Стеклодув": {"skills":[sk.investigation, sk.perception], "tools":[glassblowers_tools], "languages":[_living_exc], "chance":1},
"Часовщик": {"skills":[sk.investigation, sk.perception], "tools":[tinkers_tools], "languages":[_living_exc], "chance":1},
"Ювелир": {"skills":[sk.insight, sk.investigation], "tools":[jewelers_tools], "languages":[_living_exc], "chance":1},
"Ткач ковров": {"skills":[sk.insight, sk.perception], "tools":[weavers_tools], "languages":[_living_exc], "chance":1},
"Лудильщик": {"skills":[sk.survival, sk.investigation], "tools":[tinkers_tools], "languages":[_living_exc], "chance":1},
}
performing_arts_professions = {
"Музыкант": {"skills":[sk.performance, sk.perception], "tools":[musical_instruments], "languages":[_living_exc], "chance":21},
"Актер театра": {"skills":[sk.performance, sk.deception], "tools":[musical_instruments, weavers_tools], "languages":[_living_exc], "chance":15},
"Певец": {"skills":[sk.performance, sk.persuasion], "tools":[], "languages":[_living_exc], "chance":15},
"Танцор": {"skills":[sk.acrobatics, sk.performance], "tools":[], "languages":[_living_exc], "chance":10},
"Художник": {"skills":[sk.performance, sk.investigation],"tools":[painters_tools], "languages":[_living_exc], "chance":6},
"Поэт": {"skills":[sk.performance, sk.persuasion], "tools":[], "languages":[_living_exc], "chance":6},
"Жонглер": {"skills":[sk.sleight_of_hand, sk.performance],"tools":[], "languages":[_living_exc], "chance":4},
"Фокусник": {"skills":[sk.sleight_of_hand, sk.performance],"tools":[woodcarvers_tools], "languages":[_living_exc], "chance":4},
"Кукловод": {"skills":[sk.performance, sk.sleight_of_hand],"tools":[weavers_tools], "languages":[_living_exc], "chance":3},
"Скульптор": {"skills":[sk.perception, sk.performance], "tools":[painters_tools], "languages":[_living_exc], "chance":3},
"Бард": {"skills":[sk.performance, sk.history], "tools":[musical_instruments], "languages":[_living_exc], "chance":3},
"Оркестровый музыкант":{"skills":[sk.performance, sk.insight], "tools":[musical_instruments], "languages":[_living_exc], "chance":2},
"Трубач": {"skills":[sk.performance, sk.athletics], "tools":[horn], "languages":[_living_exc], "chance":2},
"Лютнист": {"skills":[sk.performance, sk.perception], "tools":[lute], "languages":[_living_exc], "chance":2},
"Гармонист": {"skills":[sk.performance, sk.sleight_of_hand],"tools":["Harmonica"], "languages":[_living_exc], "chance":2},
"Писатель (драматург)":{"skills":[sk.history, sk.performance], "tools":[calligraphers_supplies], "languages":[_living_exc], "chance":2},
}
military_professions = {
"Легкий пехотинец": {"skills":[sk.athletics, sk.perception], "tools":[gaming_sets], "languages":[_living_exc], "chance":15},
"Тяжелый пехотинец": {"skills":[sk.athletics, sk.intimidation], "tools":[gaming_sets], "languages":[_living_exc], "chance":10},
"Стражник": {"skills":[sk.athletics, sk.perception], "tools":[gaming_sets], "languages":[_living_exc], "chance":10},
"Ополченец": {"skills":[sk.athletics, sk.perception], "tools":[], "languages":[_living_exc], "chance":10},
"Лучник": {"skills":[sk.perception, sk.survival], "tools":[gaming_sets], "languages":[_living_exc], "chance":10},
"Копейщик": {"skills":[sk.athletics, sk.survival], "tools":[gaming_sets], "languages":[_living_exc], "chance":8},
"Кавалерист": {"skills":[sk.athletics, sk.animal_handling],"tools":[gaming_sets], "languages":[_living_exc], "chance":5},
"Гарнизонный солдат":{"skills":[sk.athletics, sk.insight], "tools":[gaming_sets], "languages":[_living_exc], "chance":5},
"Наемник": {"skills":[sk.athletics, sk.survival], "tools":[], "languages":[_living_exc], "chance":5},
"Рекрут": {"skills":[sk.athletics, sk.survival], "tools":[gaming_sets], "languages":[_living_exc], "chance":4},
"Телохранитель": {"skills":[sk.athletics, sk.insight], "tools":[], "languages":[_living_exc], "chance":3},
"Разведчик": {"skills":[sk.stealth, sk.survival], "tools":[cartographers_tools], "languages":[_living_exc], "chance":3},
"Оруженосец": {"skills":[sk.athletics, sk.perception], "tools":[smiths_tools, leatherworkers_tools, weavers_tools, gaming_sets], "languages":[_living_exc], "chance":3},
"Баллистик": {"skills":[sk.investigation, sk.athletics],"tools":[smiths_tools], "languages":[_living_exc], "chance":2},
"Фортификатор": {"skills":[sk.investigation, sk.perception],"tools":[masons_tools], "languages":[_living_exc], "chance":2},
"Гладиатор": {"skills":[sk.athletics, sk.intimidation], "tools":[], "languages":[_living_exc], "chance":2},
"Осадный инженер": {"skills":[sk.investigation, sk.survival], "tools":[carpenters_tools], "languages":[_living_exc], "chance":2},
"Морской пехотинец": {"skills":[sk.athletics, sk.perception], "tools":[navigators_tools], "languages":[_living_exc], "chance":2},
"Катапультир": {"skills":[sk.athletics, sk.survival], "tools":[masons_tools], "languages":[_living_exc], "chance":2},
"Шпион": {"skills":[sk.stealth, sk.deception], "tools":[disguise_kit, thieves_tools], "languages":[_living_exc], "chance":1},
}
criminal_professions = {
"Карманник": {"skills":[sk.sleight_of_hand, sk.stealth],"tools":[thieves_tools], "languages":[_living_exc], "chance":20},
"Мошенник": {"skills":[sk.deception, sk.persuasion], "tools":[gaming_sets], "languages":[_living_exc], "chance":15},
"Домушник": {"skills":[sk.stealth, sk.perception], "tools":[thieves_tools], "languages":[_living_exc], "chance":15},
"Контрабандист": {"skills":[sk.stealth, sk.deception], "tools":[vehicles_land, vehicles_water, thieves_tools, disguise_kit, forgery_kit], "languages":[_living_exc], "chance":10},
"Карточный шулер": {"skills":[sk.sleight_of_hand, sk.deception],"tools":[playing_card_set], "languages":[_living_exc], "chance":8},
"Скупщик краденого": {"skills":[sk.insight, sk.persuasion], "tools":[disguise_kit], "languages":[_living_exc], "chance":7},
"Медвежатник": {"skills":[sk.sleight_of_hand, sk.stealth],"tools":[thieves_tools], "languages":[_living_exc], "chance":5},
"Скотокрад": {"skills":[sk.stealth, sk.survival], "tools":[], "languages":[_living_exc], "chance":5},
"Грабитель повозок": {"skills":[sk.athletics, sk.stealth], "tools":[], "languages":[_living_exc], "chance":4},
"Угонщик повозок": {"skills":[sk.stealth, sk.sleight_of_hand],"tools":[vehicles_land], "languages":[_living_exc], "chance":3},
"Фальшивомонетчик": {"skills":[sk.deception, sk.investigation],"tools":[forgery_kit], "languages":[_living_exc], "chance":3},
"Рекетир": {"skills":[sk.intimidation, sk.athletics],"tools":[], "languages":[_living_exc], "chance":2},
"Подделыватель документов":{"skills":[sk.investigation, sk.sleight_of_hand],"tools":[forgery_kit],"languages":[_living_exc],"chance":2},
"Похититель скота": {"skills":[sk.survival, sk.stealth], "tools":[], "languages":[_living_exc], "chance":2},
"Ассасин": {"skills":[sk.stealth, sk.deception], "tools":[poisoners_kit, disguise_kit, forgery_kit], "languages":[_living_exc], "chance":1},
"Наёмный убийца": {"skills":[sk.stealth, sk.intimidation], "tools":[poisoners_kit, disguise_kit, forgery_kit], "languages":[_living_exc], "chance":1},
"Поджигатель": {"skills":[sk.stealth, sk.intimidation], "tools":[alchemists_supplies], "languages":[_living_exc], "chance":1},
"Похититель": {"skills":[sk.stealth, sk.deception], "tools":[disguise_kit, thieves_tools, forgery_kit], "languages":[_living_exc], "chance":1},
}
academic_professions = {
"Писарь": {"skills":[sk.history, sk.investigation], "tools":[calligraphers_supplies], "languages":[_living_exc], "chance":15},
"Учитель": {"skills":[sk.persuasion, sk.insight], "tools":[all_tools], "languages":[_living_exc], "chance":15},
"Аколит": {"skills":[sk.religion, sk.insight], "tools":[calligraphers_supplies], "languages":[_living_exc], "chance":12},
"Ученый": {"skills":[sk.history, sk.arcana], "tools":[calligraphers_supplies, cartographers_tools, alchemists_supplies], "languages":[_living_exc, _dead], "chance":9},
"Алхимик": {"skills":[sk.arcana, sk.investigation], "tools":[alchemists_supplies], "languages":[_living_exc], "chance":8},
"Инженер": {"skills":[sk.investigation, sk.perception],"tools":[tinkers_tools], "languages":[_living_exc], "chance":8},
"Профессор": {"skills":[sk.history, sk.persuasion], "tools":[], "languages":[_living_exc, _dead], "chance":7},
"Историк": {"skills":[sk.history, sk.insight], "tools":[], "languages":[_living_exc, _dead], "chance":4},
"Библиотекарь":{"skills":[sk.history, sk.investigation],"tools":[], "languages":[_living_exc, _dead], "chance":4},
"Картограф": {"skills":[sk.investigation, sk.perception],"tools":[cartographers_tools], "languages":[_living_exc], "chance":3},
"Археолог": {"skills":[sk.history, sk.investigation], "tools":[cartographers_tools], "languages":[_living_exc, _dead], "chance":3},
"Лингвист": {"skills":[sk.history, sk.insight], "tools":[], "languages":[_living_exc, _dead], "chance":2},
"Математик": {"skills":[sk.investigation, sk.insight], "tools":[], "languages":[_living_exc], "chance":2},
"Философ": {"skills":[sk.religion, sk.insight], "tools":[], "languages":[_living_exc], "chance":2},
"Исследователь":{"skills":[sk.nature, sk.survival], "tools":[herbalism_kit], "languages":[_living_exc, _dead], "chance":2},
"Географ": {"skills":[sk.nature, sk.history], "tools":[cartographers_tools], "languages":[_living_exc], "chance":2},
"Астроном": {"skills":[sk.arcana, sk.perception], "tools":[navigators_tools], "languages":[_living_exc], "chance":2},
}
urban_professions = {
"Извозчик": {"skills":[sk.animal_handling, sk.perception],"tools":[vehicles_land], "languages":[_living_exc], "chance":20},
"Грузчик": {"skills":[sk.athletics, sk.survival], "tools":[], "languages":[_living_exc], "chance":17},
"Цирюльник": {"skills":[sk.medicine, sk.performance], "tools":[barber_tools], "languages":[_living_exc], "chance":10},
"Трактирщик": {"skills":[sk.persuasion, sk.insight], "tools":[brewers_supplies], "languages":[_living_exc], "chance":10},
"Гонец": {"skills":[sk.acrobatics, sk.athletics], "tools":[], "languages":[_living_exc], "chance":8},
"Фонарщик": {"skills":[sk.athletics, sk.perception], "tools":[], "languages":[_living_exc], "chance":5},
"Почтальон": {"skills":[sk.athletics, sk.perception], "tools":[], "languages":[_living_exc], "chance":5},
"Пожарный": {"skills":[sk.athletics, sk.perception], "tools":[], "languages":[_living_exc], "chance":4},
"Тюремщик": {"skills":[sk.intimidation, sk.insight], "tools":[], "languages":[_living_exc], "chance":3},
"Садовник городской":{"skills":[sk.nature, sk.survival], "tools":[herbalism_kit], "languages":[_living_exc], "chance":3},
}
mercantile_professions = {
"Продавец фруктов и овощей":{"skills":[sk.nature, sk.persuasion], "tools":[], "languages":[_living_exc], "chance":15},
"Продавец тканей": {"skills":[sk.insight, sk.performance], "tools":[weavers_tools], "languages":[_living_exc], "chance":12},
"Продавец лошадей": {"skills":[sk.animal_handling, sk.persuasion],"tools":[], "languages":[_living_exc], "chance":10},
"Караванщик": {"skills":[sk.animal_handling, sk.perception],"tools":[vehicles_land], "languages":[_living_exc], "chance":10},
"Продавец оружия": {"skills":[sk.persuasion, sk.investigation],"tools":[smiths_tools], "languages":[_living_exc], "chance":10},
"Продавец ювелирных изделий":{"skills":[sk.persuasion, sk.insight], "tools":[jewelers_tools], "languages":[_living_exc], "chance":8},
"Продавец вина": {"skills":[sk.persuasion, sk.insight], "tools":[brewers_supplies], "languages":[_living_exc], "chance":8},
"Продавец мебели": {"skills":[sk.persuasion, sk.investigation],"tools":[carpenters_tools], "languages":[_living_exc], "chance":7},
"Аптекарь": {"skills":[sk.medicine, sk.arcana], "tools":[herbalism_kit], "languages":[_living_exc], "chance":6},
"Продавец книг": {"skills":[sk.history, sk.investigation], "tools":[], "languages":[_living_exc], "chance":5},
"Продавец рыбы": {"skills":[sk.survival, sk.athletics], "tools":["Fishing tackle"], "languages":[_living_exc], "chance":5},
"Торговец кожаными изделиями":{"skills":[sk.investigation, sk.insight], "tools":[leatherworkers_tools], "languages":[_living_exc], "chance":4},
"Торговец тканями": {"skills":[sk.insight, sk.persuasion], "tools":[weavers_tools], "languages":[_living_exc], "chance":3},
"Караванный охранник": {"skills":[sk.athletics, sk.perception], "tools":[gaming_sets], "languages":[_living_exc], "chance":2},
"Гильдейский казначей": {"skills":[sk.investigation, sk.insight], "tools":[calligraphers_supplies], "languages":[_living_exc], "chance":1},
"Продавец пряностей": {"skills":[sk.insight, sk.persuasion], "tools":[], "languages":[_living_exc], "chance":1},
}
construction_professions = {
"Строитель": {"skills":[sk.athletics, sk.perception], "tools":[carpenters_tools], "languages":[_living_exc], "chance":42},
"Каменщик": {"skills":[sk.athletics, sk.investigation],"tools":[masons_tools], "languages":[_living_exc], "chance":25},
"Архитектор": {"skills":[sk.investigation, sk.insight], "tools":[masons_tools], "languages":[_living_exc], "chance":12},
"Маляр": {"skills":[sk.investigation, sk.performance],"tools":[painters_tools], "languages":[_living_exc], "chance":4},
"Плотник": {"skills":[sk.athletics, sk.perception], "tools":[carpenters_tools], "languages":[_living_exc], "chance":9},
"Кровельщик": {"skills":[sk.athletics, sk.perception], "tools":["roofersTools"], "languages":[_living_exc], "chance":2},
"Реставратор": {"skills":[sk.insight, sk.investigation], "tools":["restorationTools"], "languages":[_living_exc], "chance":6},
}
medical_professions = {
"Врач": {"skills":[sk.medicine, sk.perception], "tools":["Medical instruments"], "languages":[_living_exc], "chance":52},
"Травник": {"skills":[sk.nature, sk.medicine], "tools":[herbalism_kit], "languages":[_living_exc], "chance":21},
"Хирург": {"skills":[sk.medicine, sk.investigation],"tools":["Healer's kit"], "languages":[_living_exc], "chance":14},
"Акушер": {"skills":[sk.medicine, sk.insight], "tools":["Medical instruments"], "languages":[_living_exc], "chance":8},
"Корабельный врач":{"skills":[sk.medicine, sk.perception], "tools":["Medical instruments"], "languages":[_living_exc], "chance":5},
}
religious_professions = {
"Священник": {"skills":[sk.religion, sk.persuasion], "tools":[calligraphers_supplies], "languages":[_living_exc, _dead], "prerequisite_classes":["Cleric","Paladin"], "chance":30},
"Аколит": {"skills":[sk.religion, sk.insight], "tools":[], "languages":[_living_exc, _dead], "prerequisite_classes":["Cleric","Paladin"], "chance":20},
"Монах": {"skills":[sk.religion, sk.acrobatics], "tools":[herbalism_kit], "languages":[_living_exc, _dead], "prerequisite_classes":["Cleric","Paladin"], "chance":15},
"Храмовый целитель":{"skills":[sk.medicine, sk.religion], "tools":["Healer's kit"], "languages":[_living_exc], "prerequisite_classes":["Cleric","Paladin"], "chance":15},
"Певчий в храме": {"skills":[sk.performance, sk.religion], "tools":[musical_instruments], "languages":[_living_exc], "chance":9},
"Церковный учитель":{"skills":[sk.religion, sk.persuasion], "tools":[all_tools], "languages":[_living_exc], "prerequisite_classes":["Cleric","Paladin"], "chance":7},
"Церковный писарь":{"skills":[sk.history, sk.investigation],"tools":[calligraphers_supplies], "languages":[_living_exc], "chance":4},
}
BACKGROUNDS = {
"agrarianProfessions": {"list": agrarian_professions, "chance": 30.00},
"militaryProfessions": {"list": military_professions, "chance": 20.00},
"maritimeProfessions": {"list": maritime_professions, "chance": 6.00},
"artisanProfessions": {"list": artisan_professions, "chance": 10.00},
"urbanProfessions": {"list": urban_professions, "chance": 8.00},
"criminalProfessions": {"list": criminal_professions, "chance": 6.00},
"mercantileProfessions": {"list": mercantile_professions, "chance": 5.00},
"religiousProfessions": {"list": religious_professions, "chance": 5.00},
"medicalProfessions": {"list": medical_professions, "chance": 3.00},
"constructionProfessions": {"list": construction_professions, "chance": 3.00},
"academicProfessions": {"list": academic_professions, "chance": 2.00},
"performingArtsProfessions":{"list": performing_arts_professions,"chance": 2.00},
}
# ─── Вспомогательные функции ─────────────────────────────────────────────────
def _get_random_item_with_chance(items: list[dict]) -> dict:
total = sum(item["chance"] for item in items)
r = random.random() * total
cumulative = 0.0
for item in items:
cumulative += item["chance"]
if r <= cumulative:
return item
return items[-1]
def _flatten(obj) -> list:
"""Рекурсивно разворачивает вложенные списки."""
if isinstance(obj, list):
result = []
for item in obj:
result.extend(_flatten(item))
return result
return [obj]
def _select_random_elements(array, count: int, exclude: list = None) -> list:
if exclude is None:
exclude = []
if not array:
return []
flat = list(dict.fromkeys(_flatten(array))) # уникальные, порядок сохранён
available = [el for el in flat if el not in exclude]
if not available:
return []
random.shuffle(available)
return available[:min(count, len(available))]
def _select_from_two_arrays(arr1, arr2, exclude_languages: list = None) -> dict:
if exclude_languages is None:
exclude_languages = []
tools: list = []
langs: list = []
if arr1 and arr2:
tools = _select_random_elements(arr1, 1)
langs = _select_random_elements(arr2, 1, exclude_languages)
elif arr1:
tools = _select_random_elements(arr1, 2)
elif arr2:
langs = _select_random_elements(arr2, 2, exclude_languages)
return {"tools": tools, "languages": langs}
def get_background(character_class: str, known_languages_from_race: list = None) -> dict:
"""Выбирает случайный бэкграунд с учётом класса и уже известных языков расы."""
if known_languages_from_race is None:
known_languages_from_race = []
while True:
# Шаг 1: группа бэкграундов
group_items = [{"name": k, **v} for k, v in BACKGROUNDS.items()]
selected_group = _get_random_item_with_chance(group_items)
# Шаг 2: профессия внутри группы
prof_items = [{"name": k, **v} for k, v in selected_group["list"].items()]
prof = _get_random_item_with_chance(prof_items)
# Шаг 3: проверка prerequisite_classes
prereqs = prof.get("prerequisite_classes", [])
if prereqs and character_class not in prereqs:
continue # не подходит — перебираем
# Шаг 4: скиллы / инструменты / языки
selected_skills = _select_random_elements(prof["skills"], 2)
selected_tl = _select_from_two_arrays(
prof.get("tools", []),
prof.get("languages", []),
known_languages_from_race,
)
return {
"name": prof["name"],
"skills": selected_skills,
"tools": selected_tl["tools"],
"languages": selected_tl["languages"],
}
def get_class_skills_with_background(character_class: str, background: dict) -> list[str]:
"""Выбирает скиллы класса, исключая уже взятые из бэкграунда."""
from skills import CHARACTER_CLASS_SKILLS, get_random_skills
bg_skills = background["skills"]
# Получаем список скиллов для класса персонажа
skills_list, count = CHARACTER_CLASS_SKILLS[character_class.lower()]
# Убираем из списка скиллов класса те, что уже выбраны через бекграунд
filtered = [s for s in skills_list if s not in bg_skills]
return get_random_skills(filtered, count)

376
generate_npc/bio_tables.py Normal file
View File

@ -0,0 +1,376 @@
# xge_tables.py
"""
XGE-based biography tables for D&D NPC generation.
All tables normalized to 1d100 (float weights), tuned for commoner (base chance 0.9).
"""
import random
# ──────────────────────────────────────────────────────────────────────────────
# Вспомогательные функции
# ──────────────────────────────────────────────────────────────────────────────
def _weighted_pick(table: list[tuple[float, str]]) -> str:
"""
Выбирает элемент из таблицы [(weight, value), ...].
Веса не обязаны суммироваться в 100 нормировка внутри.
"""
total = sum(w for w, _ in table)
r = random.random() * total
cumulative = 0.0
for weight, value in table:
cumulative += weight
if r <= cumulative:
return value
return table[-1][1]
def _roll_dice(n: int, sides: int, bonus: int = 0) -> int:
return sum(random.randint(1, sides) for _ in range(n)) + bonus
# ──────────────────────────────────────────────────────────────────────────────
# Birthplace
# ──────────────────────────────────────────────────────────────────────────────
BIRTHPLACE_TABLE: list[tuple[float, str]] = [
(45.00, "home"),
(8.00, "home of a family friend"),
(8.00, "home of a healer or midwife"),
(3.00, "carriage or cart"),
(4.00, "barn or outbuilding"),
(2.00, "cave"),
(2.00, "field"),
(2.00, "forest"),
(4.00, "temple"),
(1.00, "battlefield"),
(3.00, "alley or street"),
(3.00, "tavern or inn"),
(2.00, "castle or palace"),
(1.00, "sewer"),
(2.00, "among people of a different race"),
(2.00, "on a boat or ship"),
(1.00, "prison or secret organization"),
(1.00, "sage's laboratory"),
(0.30, "the Feywild"),
(0.30, "the Shadowfell"),
(0.20, "Astral or Ethereal Plane"),
(0.10, "Inner Plane"),
(0.10, "Outer Plane"),
(0.10, "another Prime Material world"), # добавлено
]
# ──────────────────────────────────────────────────────────────────────────────
# Family (кто вырастил)
# ──────────────────────────────────────────────────────────────────────────────
FAMILY_TABLE: list[tuple[float, str]] = [
(55.00, "mother and father"),
(18.00, "single mother or stepmother"),
(10.00, "single father or stepfather"),
(7.00, "adoptive family"),
(5.00, "grandparent(s)"),
(3.00, "aunt or uncle"),
(1.00, "guardian"),
(0.70, "orphanage"),
(0.20, "temple"),
(0.07, "institution"),
(0.03, "none (street)"),
]
# ──────────────────────────────────────────────────────────────────────────────
# Siblings
# ──────────────────────────────────────────────────────────────────────────────
SIBLINGS_COUNT_TABLE: list[tuple[float, str]] = [
(30.00, "none"),
(25.00, "1d3"),
(22.00, "1d4+1"),
(15.00, "1d6+2"),
(8.00, "1d8+3"),
]
BIRTH_ORDER_TABLE: list[tuple[float, str]] = [
(2.78, "twin/triplet/quadruplet"), # 2 на 2d6
(55.56, "younger"), # 37 на 2d6
(41.67, "older"), # 812 на 2d6
]
def _get_siblings() -> str:
formula = _weighted_pick(SIBLINGS_COUNT_TABLE)
if formula == "none":
return "none"
if formula == "1d3":
count = _roll_dice(1, 3)
elif formula == "1d4+1":
count = _roll_dice(1, 4, 1)
elif formula == "1d6+2":
count = _roll_dice(1, 6, 2)
elif formula == "1d8+3":
count = _roll_dice(1, 8, 3)
else:
count = 1
order = _weighted_pick(BIRTH_ORDER_TABLE)
return f"{count} ({order})"
# ──────────────────────────────────────────────────────────────────────────────
# Childhood Memories
# ──────────────────────────────────────────────────────────────────────────────
CHILDHOOD_TABLE: list[tuple[float, str]] = [
(3.00, "haunted by peers"),
(5.00, "lonely outcast"),
(10.00, "few companions"),
(47.00, "ordinary childhood"),
(17.00, "happy, few friends"),
(11.00, "sociable, well-liked"),
(7.00, "universally known"),
]
# ──────────────────────────────────────────────────────────────────────────────
# Life Events — количество по возрасту
# ──────────────────────────────────────────────────────────────────────────────
def _get_event_count(age_key: str) -> int:
if age_key == "Youth":
return 1 if random.random() < 0.80 else 0 # 1 if random.random() < 0.80 else 0
elif age_key == "Adult":
return _roll_dice(1, 2) # 1
elif age_key == "MiddleAged":
return _roll_dice(1, 3) # _roll_dice(1, 3)
elif age_key == "Old":
return _roll_dice(1, 4) # roll_dice(1, 4)
elif age_key == "Ancient":
return _roll_dice(1, 6) # _roll_dice(1, 4, 1)
return 1
# ──────────────────────────────────────────────────────────────────────────────
# Life Events — подтаблицы (теги)
# ──────────────────────────────────────────────────────────────────────────────
CAUSE_OF_DEATH_TABLE: list[tuple[float, str]] = [
(8.33, "unknown cause"),
(8.33, "murdered"),
(8.33, "killed in battle"),
(8.33, "occupational accident"),
(8.33, "accident"),
(16.67, "natural causes"),
(8.33, "apparent suicide"),
(8.33, "natural disaster"),
(8.33, "consumed by monster"),
(8.33, "executed"),
(8.33, "bizarre event"),
]
TRAGEDIES_TABLE: list[tuple[float, str]] = [
(16.67, "family loss ({})"), # заполняется причиной смерти
(8.33, "bitter friendship end"),
(8.33, "lost all possessions"),
(8.33, "wrongful imprisonment"),
(8.33, "war, home destroyed"),
(8.33, "lover disappeared"),
(8.33, "famine, sibling lost"),
(8.33, "family shame"),
(8.33, "exiled"),
(8.33, "romance ended badly"),
(8.33, "partner died ({})"), # заполняется причиной смерти
]
BOONS_TABLE: list[tuple[float, str]] = [
(10.00, "gifted a cantrip scroll"),
(10.00, "saved a commoner's life"),
(10.00, "found a riding horse"),
(10.00, "found some money"),
(10.00, "inherited a weapon"),
(10.00, "found a trinket"),
(10.00, "served a temple, owes healing"),
(10.00, "gifted a potion"),
(10.00, "found a treasure map"),
(10.00, "distant relative's stipend"),
]
ADVENTURES_TABLE: list[tuple[float, str]] = [
(10.00, "near death, scarred"),
(10.00, "grievous wound, still aches"),
(10.00, "wounded, fully recovered"),
(10.00, "contracted disease, lingering mark"),
(10.00, "poisoned, recovered"),
(10.00, "lost sentimental item"),
(10.00, "fled, abandoned companions"),
(10.00, "learned a great deal"),
(9.00, "found some treasure"),
(9.00, "found considerable treasure"),
(2.00, "found a common magic item"),
]
ARCANE_MATTERS_TABLE: list[tuple[float, str]] = [
(10.00, "charmed or frightened by spell"),
(10.00, "injured by a spell"),
(10.00, "witnessed powerful spellcasting"),
(10.00, "drank a potion"),
(10.00, "cast a spell from a scroll"),
(10.00, "affected by teleportation"),
(10.00, "turned invisible briefly"),
(10.00, "saw through an illusion"),
(10.00, "witnessed a summoning"),
(10.00, "fortune read by a diviner"),
]
SUPERNATURAL_TABLE: list[tuple[float, str]] = [
(5.00, "enslaved by fey for years"),
(5.00, "saw a demon, fled"),
(5.00, "tempted by a devil"),
(5.00, "woke up far from home"),
(10.00, "visited a holy site"),
(10.00, "witnessed an omen"),
(10.00, "escaped death, god's intervention"),
(10.00, "witnessed a minor miracle"),
(10.00, "found a haunted house"),
(5.00, "briefly possessed"),
(5.00, "saw a ghost"),
(5.00, "saw a ghoul feeding"),
(5.00, "visited in dreams by celestial or fiend"),
(5.00, "briefly visited Feywild or Shadowfell"),
(5.00, "saw a planar portal"),
]
WAR_TABLE: list[tuple[float, str]] = [
(8.33, "knocked out, no memory of battle"),
(16.67, "badly injured in battle, scarred"),
(8.33, "fled battle in shame"),
(25.00, "minor injuries, fully healed"),
(16.67, "survived with nightmares"),
(16.67, "unscathed, friends lost"),
(8.33, "acclaimed battle hero"),
]
CRIME_TABLE: list[tuple[float, str]] = [
(12.50, "murder"),
(12.50, "theft"),
(12.50, "burglary"),
(12.50, "assault"),
(12.50, "smuggling"),
(12.50, "kidnapping"),
(12.50, "extortion"),
(12.50, "counterfeiting"),
]
PUNISHMENT_TABLE: list[tuple[float, str]] = [
(25.00, "exonerated (innocent)"),
(25.00, "found not guilty (guilty)"),
(16.67, "fled, still wanted"),
(33.33, "convicted, served sentence"),
]
WEIRD_TABLE: list[tuple[float, str]] = [
(8.33, "turned into a toad for weeks"),
(8.33, "petrified until freed"),
(8.33, "enslaved by a hag or satyr"),
(8.33, "held by a dragon for months"),
(8.33, "slave in the Underdark, escaped"),
(8.33, "served a powerful adventurer"),
(8.33, "years of madness"),
(8.33, "lover was a silver dragon"),
(8.33, "nearly sacrificed by a cult"),
(8.33, "met a demigod or archfiend"),
(8.33, "swallowed by a giant fish"),
(8.33, "wasted a wish on triviality"),
]
LIFE_EVENTS_TABLE: list[tuple[float, str]] = [
(10.00, "tragedy"),
(10.00, "boon"),
(10.00, "fell in love or married"),
(10.00, "made an enemy"),
(10.00, "made a friend"),
(20.00, "worked a job"),
(5.00, "met someone important"),
(5.00, "went on an adventure"),
(5.00, "supernatural experience"),
(5.00, "fought in a battle"),
(5.00, "committed or accused of a crime"),
(4.00, "encountered something magical"),
(1.00, "something truly strange happened"),
]
def _resolve_event() -> str:
event_type = _weighted_pick(LIFE_EVENTS_TABLE)
if event_type == "tragedy":
t = _weighted_pick(TRAGEDIES_TABLE)
if "{}" in t:
cause = _weighted_pick(CAUSE_OF_DEATH_TABLE)
return t.format(cause)
return t
elif event_type == "boon":
return _weighted_pick(BOONS_TABLE)
elif event_type == "went on an adventure":
return _weighted_pick(ADVENTURES_TABLE)
elif event_type == "supernatural experience":
return _weighted_pick(SUPERNATURAL_TABLE)
elif event_type == "fought in a battle":
return _weighted_pick(WAR_TABLE)
elif event_type == "committed or accused of a crime":
crime = _weighted_pick(CRIME_TABLE)
punishment = _weighted_pick(PUNISHMENT_TABLE)
return f"{crime}{punishment}"
elif event_type == "encountered something magical":
return _weighted_pick(ARCANE_MATTERS_TABLE)
elif event_type == "something truly strange happened":
return _weighted_pick(WEIRD_TABLE)
else:
return event_type
# ──────────────────────────────────────────────────────────────────────────────
# Публичная функция
# ──────────────────────────────────────────────────────────────────────────────
def generate_bio(age_key: str) -> str:
"""
Генерирует строку Bio для NPC на основе возрастной категории.
Возвращает строку вида:
Bio: birthplace: home; raised by: mother and father; siblings: 2 (younger);
childhood: ordinary; events: family loss (murdered), worked a job.
"""
birthplace = _weighted_pick(BIRTHPLACE_TABLE)
family = _weighted_pick(FAMILY_TABLE)
siblings = _get_siblings()
childhood = _weighted_pick(CHILDHOOD_TABLE)
count = _get_event_count(age_key)
# events = [_resolve_event() for _ in range(count)]
events = []
for _ in range(count):
for _attempt in range(4):
new_event = _resolve_event()
if new_event not in events:
events.append(new_event)
break
events_str = ", ".join(events) if events else "none"
return (
f"Bio: birthplace: {birthplace}; "
f"raised by: {family}; "
f"siblings: {siblings}; "
f"childhood: {childhood}; "
f"events ({count}): {events_str}."
)

90
generate_npc/classes.js Normal file
View File

@ -0,0 +1,90 @@
// classes names
let bard = "Bard";
let barbarian = "Barbarian";
let fighter = "Fighter";
let wizard = "Wizard";
let druid = "Druid";
let cleric = "Cleric";
let warlock = "Warlock";
let monk = "Monk";
let paladin = "Paladin";
let rogue = "Rogue";
let ranger = "Ranger";
let sorcerer = "Sorcerer";
let classes = [bard, barbarian, fighter, wizard, druid, cleric, warlock,
monk, paladin, rogue, ranger, sorcerer];
// classes chances
let bardChance = 2;
let barbarianChance = 8;
let fighterChance = 26;
let wizardChance = 7;
let druidChance = 4;
let clericChance = 18;
let warlockChance = 2;
let monkChance = 2;
let paladinChance = 6;
let rogueChance = 17;
let rangerChance = 6;
let sorcererChance = 2;
let classesChance = [
bardChance,
barbarianChance,
fighterChance,
wizardChance,
druidChance,
clericChance,
warlockChance,
monkChance,
paladinChance,
rogueChance,
rangerChance,
sorcererChance
];
function getRandomClass()
{
// Вычисляем сумму всех шансов
let totalChance = classesChance.reduce((sum, chance) => sum + chance, 0);
// Генерируем случайное число от 0 до totalChance
let randomChance = Math.random() * totalChance;
// Определяем индекс класса на основе случайного числа и шансов
let cumulativeChance = 0;
let classIndex;
for (let i = 0; i < classesChance.length; i++) {
cumulativeChance += classesChance[i];
if (randomChance < cumulativeChance) {
classIndex = i;
break;
}
}
// Получаем выбранный класс
let selectedClass = classes[classIndex];
return selectedClass;
}
dv.classes = {
bard,
barbarian,
fighter,
wizard,
druid,
cleric,
warlock,
monk,
paladin,
rogue,
ranger,
sorcerer,
getRandomClass
}

75
generate_npc/classes.py Normal file
View File

@ -0,0 +1,75 @@
# classes.py
import random
BARD = "Bard"
BARBARIAN = "Barbarian"
FIGHTER = "Fighter"
WIZARD = "Wizard"
DRUID = "Druid"
CLERIC = "Cleric"
WARLOCK = "Warlock"
MONK = "Monk"
PALADIN = "Paladin"
ROGUE = "Rogue"
RANGER = "Ranger"
SORCERER = "Sorcerer"
COMMONER = "Commoner"
CLASSES = [BARD, BARBARIAN, FIGHTER, WIZARD, DRUID, CLERIC,
WARLOCK, MONK, PALADIN, ROGUE, RANGER, SORCERER]
CLASSES_CHANCE = [2, 8, 26, 7, 4, 18, 2, 2, 6, 17, 6, 2]
DEFAULT_COMMONER_CHANCE = 0.90 # 90% мира — простолюдины
def get_random_class(
allowed_classes: list[str] | None = None,
commoner_chance: float = DEFAULT_COMMONER_CHANCE,
) -> str:
"""
Двухуровневый выбор класса.
allowed_classes:
None или [] все классы, включая Commoner
["Fighter", "Rogue"] только боевые из списка, Commoner исключён
["Commoner"] всегда Commoner
["Fighter", "Commoner"] Fighter или Commoner
commoner_chance:
Вероятность выпадения Commoner (если он допустим). Default 0.90.
"""
all_allowed = not allowed_classes # [] или None → все
commoner_allowed = all_allowed or (COMMONER in allowed_classes)
combat_pool = (
{cls: chance for cls, chance in zip(CLASSES, CLASSES_CHANCE)}
if all_allowed
else {cls: chance for cls, chance in zip(CLASSES, CLASSES_CHANCE)
if cls in allowed_classes}
)
has_combat = bool(combat_pool)
has_commoner = commoner_allowed
# Крайние случаи
if has_commoner and not has_combat:
return COMMONER
if has_combat and not has_commoner:
return _pick_from_combat(combat_pool)
# Общий случай: оба варианта допустимы
if random.random() < commoner_chance:
return COMMONER
return _pick_from_combat(combat_pool)
def _pick_from_combat(pool: dict[str, float]) -> str:
"""Взвешенный выбор из пула боевых классов."""
total = sum(pool.values())
r = random.random() * total
cumulative = 0.0
for cls, chance in pool.items():
cumulative += chance
if r < cumulative:
return cls
return list(pool.keys())[-1]

View File

@ -0,0 +1,248 @@
// Dataviewjs не поддерживает импорты, кроме таких. Для обхода ограничения внутренние объекты прописываем в поля `dv`
await dv.view("scripts/henchmen/languages");
await dv.view("scripts/henchmen/norminv");
await dv.view("scripts/henchmen/races");
await dv.view("scripts/henchmen/abilities");
await dv.view("scripts/henchmen/skills");
await dv.view("scripts/henchmen/classes");
await dv.view("scripts/henchmen/backgrounds");
await dv.view("scripts/henchmen/ages");
await dv.view("scripts/henchmen/psychoTypes");
// Константы
const maxCount = 1000;
const degree = 0.7;
// Параметры фита нормальным распределением
const I_a = 201.7348602;
const II_a = 52.38963682;
const III_a = 17.94924525;
const IV_a = 7.481035692;
const V_a = 3.492919247;
const VI_a = 1.497346392;
let muAs = [I_a, II_a, III_a, IV_a, V_a, VI_a];
const I_b = -1.445350459;
const II_b = -1.411086938;
const III_b = -1.350400144;
const IV_b = -1.148846596;
const V_b = -1.215297703;
const VI_b = -1.421497636;
let muBs = [I_b, II_b, III_b, IV_b, V_b, VI_b];
const I_σ_a = 57.62483553;
const II_σ_a = 12.84292177;
const III_σ_a = 4.541993632;
const IV_σ_a = 1.906368905;
const V_σ_a = 1.667679638;
const VI_σ_a = 0.5420709982;
let muSigmaAs = [I_σ_a, II_σ_a, III_σ_a, IV_σ_a, V_σ_a, VI_σ_a];
const I_σ_b = -1.514703422;
const II_σ_b = -1.166486302;
const III_σ_b = -0.9321346977;
const IV_σ_b = -0.9380410427;
const V_σ_b = -0.6880944825;
const VI_σ_b = -0.3555551311;
let muSigmaBs = [I_σ_b, II_σ_b, III_σ_b, IV_σ_b, V_σ_b, VI_σ_b];
// Constans
let alignments = ["LG", "LN", "LE", "NG", "TN", "NE", "CG", "CN", "CE"];
function getRandomGender() {
return Math.random() < 0.68 ? "M" : "F";
}
function formatLanguages(languages) {
return languages
.map((lang) => {
let result = `${lang.name} [${lang.alphabet}`;
if (lang.speakers) {
result += `; ${lang.speakers}`;
}
result += "]";
return result;
})
.join(", ");
}
function recalc_generateHenchmen(maxCount, muA, muB, sigmaA, sigmaB, degree) {
// Функция для броска кубиков 3d6
// Переменная для хранения итогового текста
var resultText = "";
// Проход по уровням от 3 до 10
for (var level = 1; level < 8; level++) {
let mu = muA * Math.exp(muB * level ** degree);
let sigma = sigmaA * Math.exp(sigmaB * level ** degree);
var hench_count = recalc_countFromNormalDistribution(mu, sigma);
if (hench_count > 0) {
if (level !== "" && hench_count !== "") {
// Минимум между BN и maxCount
var minHenchCount = Math.min(hench_count, maxCount);
// Текст для текущего уровня
var levelText = "### Level " + level + " (" + minHenchCount + "):\n";
// Генерируем строки для персонажей (персонаж1, персонаж2, и т.д.)
for (var j = 1; j <= minHenchCount; j++) {
var alignmentIndex = Math.floor(Math.random() * alignments.length);
var randomAlignment = alignments[alignmentIndex];
let race = dv.races.selectRandomRaceAndSubrace(dv.races.races);
let raceString = "**";
// Проверка на совпадение расы и подрасы
if (race.race === race.subrace || !race.subrace) {
raceString += race.race; // Если совпадает, возвращаем только расу
} else {
raceString += `${race.subrace} ${race.race}`; // Если разные, возвращаем в формате "раса подраса"
}
raceString += "**";
let gender = getRandomGender();
let fullNameObject = dv.races.getName(race, gender);
let fullName = fullNameObject.name;
let knownLanguagesFromRace = dv.races.getLanguages(
race,
fullNameObject
);
raceString += " (" + formatLanguages(knownLanguagesFromRace) + ")";
var randomClass = dv.classes.getRandomClass();
let abilityScores = dv.abilities.getAbilityScores(randomClass);
const [ageCategory, ageExplanation, agedAbilityScores] = dv.ages.getAgeAndModifyAbilities(abilityScores);
let ageString = "**" + ageCategory + "** (" + ageExplanation + ")";
let background = dv.backgrounds.getBackground(randomClass);
let backgroundString =
background.name +
" (" +
background.skills.join(", ") +
", " +
background.tools.join(", ") +
", " +
formatLanguages(background.languages) +
")";
let chosenClassSkills = dv.backgrounds.getClassSkillsWithBackground(
randomClass,
background
);
let chosenClassSkillsString = chosenClassSkills.join(", ");
// Формируем текст с атрибутами
var attributes =
"Str " +
agedAbilityScores["Strength"] +
", Dex " +
agedAbilityScores["Dexterity"] +
", Con " +
agedAbilityScores["Constitution"] +
", Int " +
agedAbilityScores["Intelligence"] +
", Wis " +
agedAbilityScores["Wisdom"] +
", Cha " +
agedAbilityScores["Charisma"];
let psychoType = dv.psychoTypes.getPsychosocialTypeAndArchetype();
let psychoTypeString = "temperament: " + psychoType.temperament.name + ", " +
"psychosocial type: " + psychoType.psychosocialType.name + ", " +
"archetype by C.Jung: " + psychoType.archetype.name;
// Добавляем строку для персонажа с атрибутами
levelText +=
"#" +
j +
" - **" +
randomClass +
"** lvl " +
level +
"; " +
gender +
"; " +
fullName +
"; race: " +
raceString +
"; age: " +
ageString +
"; " +
attributes +
"; al " +
randomAlignment +
"; class skills: " +
chosenClassSkillsString +
"; background: " +
backgroundString +
"; " +
psychoTypeString +
".\n";
}
// Добавляем результат для текущего уровня в итоговый текст
resultText += levelText + "\n";
}
resultText += "---\n";
}
}
// Возвращаем итоговый текст
return resultText;
}
function recalc_generateNormalRandom(mu, sigma) {
return dv.norminv.generateNormalRandom(mu, sigma);
}
// =IF(μ >= 1, FLOOR(μ + σ * NORMINV(RAND(), 0, 1)), IF(RAND() <= μ, 1, 0))
function recalc_countFromNormalDistribution(mu, sigma) {
const rand = Math.random();
if (mu >= 1) {
return Math.floor(dv.norminv.generateNormalRandom(mu, sigma));
// return Math.floor(mu + sigma * norminv(rand));
} else {
return rand <= mu ? 1 : 0;
}
}
// Функция для отображения таблицы
function show_table(dv, marketLevel, basePath) {
/*// Отображаем таблицу с найденными локациями
dv.table(["Image", "Name", "Main Info"], getDescendantLocations(dv, targetLocations, basePath)); // Изменен порядок колонок*/
let curMarketMuA = muAs[marketLevel - 1];
let curMarketMuB = muBs[marketLevel - 1];
let curMarketSigmaA = muSigmaAs[marketLevel - 1];
let curMarketSigmaB = muSigmaBs[marketLevel - 1];
// Вызов функции с подстановкой значений
const result = recalc_generateHenchmen(
maxCount,
curMarketMuA,
curMarketMuB,
curMarketSigmaA,
curMarketSigmaB,
degree
);
dv.span(result);
}
// Функция для запуска отображения таблицы
function run_show_table(...args) {
let dv = args[0].arg1;
//let targetLocations = args[0].arg2;
let marketLevel = args[0].arg2;
let basePath = this.app.vault.adapter.basePath; // Получаем базовый путь к хранилищу
show_table(dv, marketLevel, basePath);
}
run_show_table(input);

View File

@ -0,0 +1,435 @@
"""
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.01.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.01.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)

474
generate_npc/languages.js Normal file
View File

@ -0,0 +1,474 @@
let Aragrakh = {
name: "Aragrakh",
alphabet: "Draconic",
speakers: "Old high wyrm",
chance: 15.00 // Древний язык драконов, имеет высокое историческое значение.
};
let Hulgorkyn = {
name: "Hulgorkyn",
alphabet: "Dethek",
speakers: "Archaic orc",
chance: 10.00 // Древний язык орков, имеет культурное значение среди их потомков.
};
let Loross = {
name: "Loross",
alphabet: "Draconic",
speakers: "Netherese noble tongue",
chance: 20.00 // Язык благородных Нетерил, высоко ценимый среди магов и историков.
};
let Netherese = {
name: "Netherese",
alphabet: "Draconic",
speakers: "A precursor of Halruaan",
chance: 17.00 // Прото-язык, из которого развился язык Халруаа, важный для магии.
};
let Roushoum = {
name: "Roushoum",
alphabet: "Imaskari",
speakers: "A precursor of Tuigan",
chance: 13.00 // Язык предков Туиганов, важный для древних степных народов.
};
let Seldruin = {
name: "Seldruin",
alphabet: "Hamarfae",
speakers: "Elven high magic",
chance: 12.00 // Эльфийская магия, язык высокоразвитой магической традиции.
};
let Thorass = {
name: "Thorass",
alphabet: "Thorass",
speakers: "Old Common",
chance: 13.00 // Древний общий язык, предшественник современных диалектов.
};
let deadLanguages = [
Aragrakh,
Hulgorkyn,
Loross,
Netherese,
Roushoum,
Seldruin,
Thorass
];
let Aglorondan = {
name: "Aglorondan",
alphabet: "Espruar",
speakers: "Aglarond, Altumbel",
chance: 2.50 // Агларонд - небольшой регион, язык не особо распространён.
};
let Alzhedo = {
name: "Alzhedo",
alphabet: "Thorass",
speakers: "Calimshan",
chance: 4.50 // Калимшан — крупный регион с богатой торговлей и историей.
};
let Chessentan = {
name: "Chessentan",
alphabet: "Thorass",
speakers: "Chessenta",
chance: 3.00 // Чессента - важный, но локальный язык.
};
let Chondathan = {
name: "Chondathan",
alphabet: "Thorass",
speakers: "Amn, Chondath, Cormyr, the Dalelands, the Dragon Coast, the civilized North, Sembia, the Silver Marches, the Sword Coast, Tethyr, Waterdeep, the Western Heartlands, the Vilhon Reach",
chance: 12.00 // Очень распространённый язык по всему Фаэруну, в том числе на Мече.
};
let Chultan = {
name: "Chultan",
alphabet: "Draconic",
speakers: "Chult",
chance: 2.00 // Язык Чульта, но географически ограничен его джунглями.
};
let Common = {
name: "Common",
alphabet: "Thorass",
speakers: "", // Everywhere on Faerûn's surface (trade language)
chance: 25.00 // Общий язык, на котором говорят почти везде.
};
let Damaran = {
name: "Damaran",
alphabet: "Dethek",
speakers: "Damara, the Great Dale, Impiltur, the Moonsea, Narfell, Thesk, Vaasa, the Vast",
chance: 4.00 // Локальный, но важный язык в восточных регионах.
};
let Dambrathan = {
name: "Dambrathan",
alphabet: "Espruar",
speakers: "Dambrath",
chance: 2.00 // Язык Дамбрата, распространён лишь в небольшом регионе.
};
let Durpari = {
name: "Durpari",
alphabet: "Thorass",
speakers: "Durpar, Estagund, Var, Veldorn",
chance: 3.00 // Региональный язык юго-восточной части Фаэруна.
};
let Halruaan = {
name: "Halruaan",
alphabet: "Draconic",
speakers: "Halruaa, Nimbral",
chance: 2.50 // Язык волшебников Халруаа, важен в магических кругах, но локализован.
};
let Illuskan = {
name: "Illuskan",
alphabet: "Thorass",
speakers: "Luskan, Mintarn, the Moonshaes, the Savage North (uncivilized areas), Ruathym, the Uthgardt barbarians",
chance: 6.00 // Язык варваров и северных народов, важен на северо-западе.
};
let Lantanese = {
name: "Lantanese",
alphabet: "Draconic",
speakers: "Lantan",
chance: 1.50 // Язык Лантана, технологически развитого острова, но крайне ограничен.
};
let Midani = {
name: "Midani",
alphabet: "Thorass",
speakers: "Zakhara, the Bedine",
chance: 3.50 // Язык Закхары, богатого южного региона.
};
let Mulani = {
name: "Mulani",
alphabet: "Thorass", // не корректно, надо искать
speakers: "East Faerûn",
chance: 2.00
};
let Mulhorandi = {
name: "Mulhorandi",
alphabet: "Celestial",
speakers: "Mulhorand, Murghôm, Semphar",
chance: 3.00 // Язык могущественной империи Мулоранда.
};
let MulhorandiVar = {
name: "Mulhorandi (var.)",
alphabet: "Infernal",
speakers: "Thay",
chance: 2.20 // Вариант языка, используемый в Тэе, известен своей мощной магической культурой.
};
let Nexalan = {
name: "Nexalan",
alphabet: "Draconic",
speakers: "Maztica",
chance: 1.50 // Локализован в Мазтике, ограниченная область применения.
};
let Rashemi = {
name: "Rashemi",
alphabet: "Thorass",
speakers: "Rashemen",
chance: 2.50 // Язык Расхемена, в основном используемый внутри страны.
};
let Serusan = {
name: "Serusan",
alphabet: "Aquan",
speakers: "Inner Sea (aquatic trade language)",
chance: 1.00 // Язык подводных торговцев, малоизвестен на поверхности.
};
let Shaaran = {
name: "Shaaran",
alphabet: "Dethek",
speakers: "Lake of Steam, Lapaliiya, Sespech, the Shaar",
chance: 2.50 // Используется кочевниками Шаара, в определённых южных регионах.
};
let KoryoLanguage = {
name: "Koryo",
alphabet: "Draconic", // не корректно, надо проверять
speakers: "Kara-Tur (Koryo)",
chance: 0.30
};
let Shou = {
name: "Shou",
alphabet: "Draconic",
speakers: "Kara-Tur",
chance: 2.00 // Язык дальневосточной территории Кара-Тур.
};
let Tashalan = {
name: "Tashalan",
alphabet: "Dethek",
speakers: "Black Jungle, Mhair Jungle, Samarach, Tashalar, Thindol",
chance: 2.00 // Локализованный язык тропических джунглей.
};
let Tuigan = {
name: "Tuigan",
alphabet: "Thorass",
speakers: "Hordelands",
chance: 1.50 // Язык степных племён кочевников в Хорделенде.
};
let Turmic = {
name: "Turmic",
alphabet: "Thorass",
speakers: "Turmish",
chance: 2.00 // Локализован в Турмише, важен для региональных торговцев.
};
let Uluik = {
name: "Uluik",
alphabet: "Thorass",
speakers: "Great Glacier",
chance: 1.00 // Язык обледеневших регионов, малоизвестен за пределами ледников.
};
let Undercommon = {
name: "Undercommon",
alphabet: "Espruar",
speakers: "Underdark (trade language)",
chance: 4.00 // Используется в Подземье для торговли.
};
let Untheric = {
name: "Untheric",
alphabet: "Dethek",
speakers: "Unther",
chance: 3.50 // Язык древней Унтерской империи.
};
// **Дополнительные языки**
let Dwarvish = {
name: "Dwarvish",
alphabet: "Dethek",
speakers: "Дварфы",
chance: 6.00 // Используется дварфами.
};
let Elvish = {
name: "Elvish",
alphabet: "Espruar",
speakers: "Эльфы",
chance: 8.00 // Используется эльфами.
};
let Draconic = {
name: "Draconic",
alphabet: "Iokharic",
speakers: "Драконы, Драконорожденные, Кобольды, Ящеролюди, Троглодиты",
chance: 5.00 // Древний язык драконов, используется различными драконьими существами.
};
let GiantLanguage = {
name: "Giant",
alphabet: "Dethek",
speakers: "Огры, Великаны, Голиафы, Фирболги",
chance: 4.00 // Язык великанов и их родни.
};
let GnomishLanguage = {
name: "Gnomish",
alphabet: "Dethek",
speakers: "Гномы",
chance: 3.00 // Используется гномами.
};
let GoblinLanguage = {
name: "Goblin",
alphabet: "Dethek",
speakers: "Гоблины, Хобгоблины, Багбиры",
chance: 4.00 // Язык гоблиноидных рас.
};
let HalflingLanguage = {
name: "Halfling",
alphabet: "Espruar",
speakers: "Полурослики",
chance: 3.00 // Используется полуросликами.
};
let OrcLanguage = {
name: "Orc",
alphabet: "Dethek",
speakers: "Орки, Полуорки",
chance: 4.00 // Используется орками и их роднёй.
};
let Infernal = {
name: "Infernal",
alphabet: "Infernal",
speakers: "Тифлинги, Дьяволы",
chance: 2.00 // Язык дьяволов, используется тифлингами.
};
let Celestial = {
name: "Celestial",
alphabet: "Celestial",
speakers: "Аасимары, Небожители",
chance: 1.50 // Язык небожителей.
};
let Primordial = {
name: "Primordial",
alphabet: "Dethek",
speakers: "Элементали, Дженази, Тритоны",
chance: 1.50 // Язык элементалей, включает диалекты.
};
let Sylvan = {
name: "Sylvan",
alphabet: "Espruar",
speakers: "Фейские существа, Сатиры",
chance: 1.50 // Язык фей.
};
let Abyssal = {
name: "Abyssal",
alphabet: "Infernal",
speakers: "Демоны, Юан-ти Чистокровные",
chance: 1.00 // Язык демонов.
};
let GithLanguage = {
name: "Gith",
alphabet: "Espruar",
speakers: "Гитьянки, Гитьзераи",
chance: 0.50 // Язык гитов.
};
let LeoninLanguage = {
name: "Leonin",
alphabet: "Common",
speakers: "Леонины",
chance: 0.50 // Язык леонинов.
};
let livingLanguages = [
Aglorondan,
Alzhedo,
Chessentan,
Chondathan,
Chultan,
Common,
Damaran,
Dambrathan,
Durpari,
Halruaan,
Illuskan,
Lantanese,
Midani,
Mulani,
Mulhorandi,
MulhorandiVar,
Nexalan,
Rashemi,
Serusan,
Shaaran,
KoryoLanguage,
Shou,
Tashalan,
Tuigan,
Turmic,
Uluik,
Undercommon,
Untheric,
// Дополнительные языки
Dwarvish,
Elvish,
Draconic,
GiantLanguage,
GnomishLanguage,
GoblinLanguage,
HalflingLanguage,
OrcLanguage,
Infernal,
Celestial,
Primordial,
Sylvan,
Abyssal,
GithLanguage,
LeoninLanguage
];
let livingLanguagesExceptCommon = livingLanguages.filter(language => language.name !== "Common");
dv.languages = {
deadLanguages,
Aragrakh,
Hulgorkyn,
Loross,
Netherese,
Roushoum,
Seldruin,
Thorass,
livingLanguages,
livingLanguagesExceptCommon,
Aglorondan,
Alzhedo,
Chessentan,
Chondathan,
Chultan,
Common,
Damaran,
Dambrathan,
Durpari,
Halruaan,
Illuskan,
Lantanese,
Midani,
Mulani,
Mulhorandi,
MulhorandiVar,
Nexalan,
Rashemi,
Serusan,
Shaaran,
KoryoLanguage,
Shou,
Tashalan,
Tuigan,
Turmic,
Uluik,
Undercommon,
Untheric,
// Дополнительные языки
Dwarvish,
Elvish,
Draconic,
GiantLanguage,
GnomishLanguage,
GoblinLanguage,
HalflingLanguage,
OrcLanguage,
Infernal,
Celestial,
Primordial,
Sylvan,
Abyssal,
GithLanguage,
LeoninLanguage
}

74
generate_npc/languages.py Normal file
View File

@ -0,0 +1,74 @@
# languages.py
# ─── Мёртвые языки ───────────────────────────────────────────────────────────
Aragrakh = {"name": "Aragrakh", "alphabet": "Draconic", "speakers": "Old high wyrm", "chance": 15.00}
Hulgorkyn = {"name": "Hulgorkyn", "alphabet": "Dethek", "speakers": "Archaic orc", "chance": 10.00}
Loross = {"name": "Loross", "alphabet": "Draconic", "speakers": "Netherese noble tongue", "chance": 20.00}
Netherese = {"name": "Netherese", "alphabet": "Draconic", "speakers": "A precursor of Halruaan", "chance": 17.00}
Roushoum = {"name": "Roushoum", "alphabet": "Imaskari", "speakers": "A precursor of Tuigan", "chance": 13.00}
Seldruin = {"name": "Seldruin", "alphabet": "Hamarfae", "speakers": "Elven high magic", "chance": 12.00}
Thorass = {"name": "Thorass", "alphabet": "Thorass", "speakers": "Old Common", "chance": 13.00}
dead_languages = [Aragrakh, Hulgorkyn, Loross, Netherese, Roushoum, Seldruin, Thorass]
# ─── Живые языки ─────────────────────────────────────────────────────────────
Aglorondan = {"name": "Aglorondan", "alphabet": "Espruar", "speakers": "Aglarond, Altumbel", "chance": 2.50}
Alzhedo = {"name": "Alzhedo", "alphabet": "Thorass", "speakers": "Calimshan", "chance": 4.50}
Chessentan = {"name": "Chessentan", "alphabet": "Thorass", "speakers": "Chessenta", "chance": 3.00}
Chondathan = {"name": "Chondathan", "alphabet": "Thorass", "speakers": "Amn, Chondath, Cormyr, the Dalelands, the Dragon Coast, the civilized North, Sembia, ...", "chance": 12.00}
Chultan = {"name": "Chultan", "alphabet": "Draconic", "speakers": "Chult", "chance": 2.00}
Common = {"name": "Common", "alphabet": "Thorass", "speakers": "", "chance": 25.00}
Damaran = {"name": "Damaran", "alphabet": "Dethek", "speakers": "Damara, the Great Dale, Impiltur, the Moonsea, Narfell, Thesk, Vaasa, the Vast", "chance": 4.00}
Dambrathan = {"name": "Dambrathan", "alphabet": "Espruar", "speakers": "Dambrath", "chance": 2.00}
Durpari = {"name": "Durpari", "alphabet": "Thorass", "speakers": "Durpar, Estagund, Var, Veldorn", "chance": 3.00}
Halruaan = {"name": "Halruaan", "alphabet": "Draconic", "speakers": "Halruaa, Nimbral", "chance": 2.50}
Illuskan = {"name": "Illuskan", "alphabet": "Thorass", "speakers": "Luskan, Mintarn, the Moonshaes, the Savage North, Ruathym, the Uthgardt barbarians", "chance": 6.00}
Lantanese = {"name": "Lantanese", "alphabet": "Draconic", "speakers": "Lantan", "chance": 1.50}
Midani = {"name": "Midani", "alphabet": "Thorass", "speakers": "Zakhara, the Bedine", "chance": 3.50}
Mulani = {"name": "Mulani", "alphabet": "Thorass", "speakers": "East Faerûn", "chance": 2.00}
Mulhorandi = {"name": "Mulhorandi", "alphabet": "Celestial", "speakers": "Mulhorand, Murghôm, Semphar", "chance": 3.00}
MulhorandiVar= {"name": "Mulhorandi (var.)", "alphabet": "Infernal", "speakers": "Thay", "chance": 2.20}
Nexalan = {"name": "Nexalan", "alphabet": "Draconic", "speakers": "Maztica", "chance": 1.50}
Rashemi = {"name": "Rashemi", "alphabet": "Thorass", "speakers": "Rashemen", "chance": 2.50}
Serusan = {"name": "Serusan", "alphabet": "Aquan", "speakers": "Inner Sea (aquatic trade language)", "chance": 1.00}
Shaaran = {"name": "Shaaran", "alphabet": "Dethek", "speakers": "Lake of Steam, Lapaliiya, Sespech, the Shaar", "chance": 2.50}
KoryoLanguage= {"name": "Koryo", "alphabet": "Draconic", "speakers": "Kara-Tur (Koryo)", "chance": 0.30}
Shou = {"name": "Shou", "alphabet": "Draconic", "speakers": "Kara-Tur", "chance": 2.00}
Tashalan = {"name": "Tashalan", "alphabet": "Dethek", "speakers": "Black Jungle, Mhair Jungle, Samarach, Tashalar, Thindol", "chance": 2.00}
Tuigan = {"name": "Tuigan", "alphabet": "Thorass", "speakers": "Hordelands", "chance": 1.50}
Turmic = {"name": "Turmic", "alphabet": "Thorass", "speakers": "Turmish", "chance": 2.00}
Uluik = {"name": "Uluik", "alphabet": "Thorass", "speakers": "Great Glacier", "chance": 1.00}
Undercommon = {"name": "Undercommon", "alphabet": "Espruar", "speakers": "Underdark (trade language)", "chance": 4.00}
Untheric = {"name": "Untheric", "alphabet": "Dethek", "speakers": "Unther", "chance": 3.50}
# Дополнительные языки рас
Dwarvish = {"name": "Dwarvish", "alphabet": "Dethek", "speakers": "Дварфы", "chance": 6.00}
Elvish = {"name": "Elvish", "alphabet": "Espruar", "speakers": "Эльфы", "chance": 8.00}
Draconic = {"name": "Draconic", "alphabet": "Iokharic", "speakers": "Драконы, Драконорожденные, Кобольды, ...", "chance": 5.00}
GiantLanguage = {"name": "Giant", "alphabet": "Dethek", "speakers": "Огры, Великаны, Голиафы, Фирболги", "chance": 4.00}
GnomishLanguage={"name": "Gnomish", "alphabet": "Dethek", "speakers": "Гномы", "chance": 3.00}
GoblinLanguage= {"name": "Goblin", "alphabet": "Dethek", "speakers": "Гоблины, Хобгоблины, Багбиры", "chance": 4.00}
HalflingLanguage={"name": "Halfling", "alphabet": "Espruar", "speakers": "Полурослики", "chance": 3.00}
OrcLanguage = {"name": "Orc", "alphabet": "Dethek", "speakers": "Орки, Полуорки", "chance": 4.00}
Infernal = {"name": "Infernal", "alphabet": "Infernal", "speakers": "Тифлинги, Дьяволы", "chance": 2.00}
Celestial = {"name": "Celestial", "alphabet": "Celestial", "speakers": "Аасимары, Небожители", "chance": 1.50}
Primordial = {"name": "Primordial","alphabet": "Dethek", "speakers": "Элементали, Дженази, Тритоны", "chance": 1.50}
Sylvan = {"name": "Sylvan", "alphabet": "Espruar", "speakers": "Фейские существа, Сатиры", "chance": 1.50}
Abyssal = {"name": "Abyssal", "alphabet": "Infernal", "speakers": "Демоны, Юан-ти Чистокровные", "chance": 1.00}
GithLanguage = {"name": "Gith", "alphabet": "Espruar", "speakers": "Гитьянки, Гитьзераи", "chance": 0.50}
LeoninLanguage= {"name": "Leonin", "alphabet": "Common", "speakers": "Леонины", "chance": 0.50}
living_languages = [
Aglorondan, Alzhedo, Chessentan, Chondathan, Chultan, Common,
Damaran, Dambrathan, Durpari, Halruaan, Illuskan, Lantanese,
Midani, Mulani, Mulhorandi, MulhorandiVar, Nexalan, Rashemi,
Serusan, Shaaran, KoryoLanguage, Shou, Tashalan, Tuigan, Turmic,
Uluik, Undercommon, Untheric,
Dwarvish, Elvish, Draconic, GiantLanguage, GnomishLanguage,
GoblinLanguage, HalflingLanguage, OrcLanguage, Infernal, Celestial,
Primordial, Sylvan, Abyssal, GithLanguage, LeoninLanguage,
]
living_languages_except_common = [lang for lang in living_languages if lang["name"] != "Common"]

13
generate_npc/norminv.js Normal file
View File

@ -0,0 +1,13 @@
function generateNormalRandom(mean, stdDev) { // математическое ожидание (mean) и стандартное отклонение (stdDev)
var u1 = Math.random(); // Генерация случайной величины
var u2 = Math.random();
var z0 = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2); // Box-Muller transform
// Преобразование в нормальное распределение с указанным мат. ожиданием и стандартным отклонением
return z0 * stdDev + mean;
}
dv.norminv = {
generateNormalRandom
}

14
generate_npc/norminv.py Normal file
View File

@ -0,0 +1,14 @@
# norminv.py
import random
import math
def generate_normal_random(mean: float, std_dev: float) -> float:
"""Box-Muller transform → нормальное распределение N(mean, std_dev)."""
u1 = random.random()
u2 = random.random()
# защита от log(0)
while u1 == 0:
u1 = random.random()
z0 = math.sqrt(-2.0 * math.log(u1)) * math.cos(2.0 * math.pi * u2)
return z0 * std_dev + mean

244
generate_npc/psychoTypes.js Normal file
View File

@ -0,0 +1,244 @@
// https://psychology.tips/types-of-temperament-and-their-meaning/
let temperaments = {
Sanguine: {
name: "Сангвиник",
chance: 20, // 20%
explanation:
"Жизнерадостный, энергичный и общительный, легко адаптируется к новым ситуациям и любит активную жизнь.",
},
Choleric: {
name: "Холерик",
chance: 15, // 15%
explanation:
"Целеустремленный и решительный, склонен к лидерству и быстрому принятию решений, хотя иногда проявляет вспыльчивость.",
},
Melancholic: {
name: "Меланхолик",
chance: 35, // 35%
explanation:
"Чувствительный и задумчивый, склонен к глубоким размышлениям и эмоциональной глубине, часто сосредоточен на деталях.",
},
Phlegmatic: {
name: "Флегматик",
chance: 30, // 30%
explanation:
"Спокойный и уравновешенный, редко подвержен стрессу и предпочитает стабильность, хороший слушатель.",
},
};
let psychosocialTypes = {
DonKihot: {
name: "Дон Кихот",
chance: 10, // 10%
explanation:
"Интуитивно-логический экстраверт, склонен к исследованию новых идей и подходов, сочетая интуицию возможностей и структурную логику.",
},
Duma: {
name: "Дюма",
chance: 8, // 8%
explanation:
"Сенсорно-этический интроверт, мягкий, ориентированный на гармонию и комфорт, использует сенсорику ощущений и этику эмоций.",
},
Gyugo: {
name: "Гюго",
chance: 9, // 9%
explanation:
"Этико-сенсорный экстраверт, энергичный, выражающий эмоции, склонен к мотивации и поддержке, управляет этикой эмоций и сенсорикой ощущений.",
},
Robespier: {
name: "Робеспьер",
chance: 7, // 7%
explanation:
"Логико-интуитивный интроверт, рационален, склонен к анализу и систематизации, использует структурную логику и интуицию возможностей.",
},
Zhukov: {
name: "Жуков",
chance: 10, // 10%
explanation:
"Сенсорно-логический экстраверт, решителен, настойчив, лидер по натуре, использует сенсорику силы и деловую логику.",
},
Yesenin: {
name: "Есенин",
chance: 8, // 8%
explanation:
"Интуитивно-этический интроверт, мечтателен, склонен к глубоким переживаниям, управляет интуицией времени и этикой эмоций.",
},
Napoleon: {
name: "Наполеон",
chance: 10, // 10%
explanation:
"Сенсорно-этический экстраверт, харизматичен, уверен в себе, лидер, использует сенсорику силы и этику отношений.",
},
Balzac: {
name: "Бальзак",
chance: 6, // 6%
explanation:
"Интуитивно-логический интроверт, пессимистичен, видит риски и угрозы, использует интуицию времени и структурную логику.",
},
JackLondon: {
name: "Джек Лондон",
chance: 10, // 10%
explanation:
"Логико-интуитивный экстраверт, ориентирован на достижение целей, новые идеи и эффективность.",
},
Draizer: {
name: "Драйзер",
chance: 7, // 7%
explanation:
"Этико-сенсорный интроверт, принципиален, привержен моральным стандартам, использует этику отношений и сенсорику силы.",
},
Shtirlic: {
name: "Штирлиц",
chance: 9, // 9%
explanation:
"Логико-сенсорный экстраверт, логичен, ориентирован на достижения, использует структурную логику и сенсорику силы.",
},
Geksli: {
name: "Гексли",
chance: 8, // 8%
explanation:
"Интуитивно-этический экстраверт, жизнерадостен, легко находит общий язык, использует интуицию возможностей и этику эмоций.",
},
Gaben: {
name: "Габен",
chance: 10, // 10%
explanation:
"Сенсорно-логический интроверт, спокойный, ценит комфорт, управляет сенсорикой ощущений и деловой логикой.",
},
MaximGorkiy: {
name: "Максим Горький",
chance: 8, // 8%
explanation:
"Логико-сенсорный интроверт, методичен, привержен порядку, использует структурную логику и сенсорику силы.",
},
Hamlet: {
name: "Гамлет",
chance: 9, // 9%
explanation:
"Этико-интуитивный экстраверт, склонен к драматичности, использует этику эмоций и интуицию времени.",
},
Dostoevskiy: {
name: "Достоевский",
chance: 8, // 8%
explanation:
"Этико-интуитивный интроверт, чувствителен, склонен к самопожертвованию, использует этику отношений и интуицию возможностей.",
},
};
let archetypesAccordingToCarlJung = {
SimpleMinded: {
name: "Простодушный",
chance: 7, // 7%
explanation:
"Символизирует невинность и открытость, избегает конфликтов, ищет безопасности.",
},
GoodFellow: {
name: "Славный малый",
chance: 8, // 8%
explanation:
"Добрый, отзывчивый, старается помогать и поддерживать других.",
},
Warrior: {
name: "Воин",
chance: 10, // 10%
explanation:
"Смелый и решительный, стремится преодолевать трудности и защищать слабых.",
},
Caregiver: {
name: "Опекун",
chance: 9, // 9%
explanation:
"Заботливый и ответственный, стремится защищать и заботиться о других.",
},
Seeker: {
name: "Искатель",
chance: 10, // 10%
explanation:
"Любознательный и независимый, стремится к новым знаниям и открытиям.",
},
Lover: {
name: "Любовник",
chance: 8, // 8%
explanation:
"Эмоциональный и страстный, стремится к гармонии и близости с другими.",
},
Rebel: {
name: "Бунтарь",
chance: 7, // 7%
explanation:
"Протестует против устоявшихся норм, стремится к переменам и свободе.",
},
Creator: {
name: "Творец",
chance: 9, // 9%
explanation:
"Ориентирован на создание и креативность, стремится создавать новое.",
},
Ruler: {
name: "Правитель",
chance: 10, // 10%
explanation:
"Стремится к порядку и контролю, управляет другими и поддерживает стабильность.",
},
Magician: {
name: "Маг",
chance: 6, // 6%
explanation:
"Ищет скрытые знания и трансформацию, стремится изменить реальность.",
},
Sage: {
name: "Мудрец",
chance: 9, // 9%
explanation: "Стремится к знаниям и мудрости, анализирует и ищет истину.",
},
Jester: {
name: "Шут",
chance: 7, // 7%
explanation:
"Игривый и остроумный, стремится разрушить шаблоны и принести радость.",
},
};
// Функция для генерации случайной комбинации типа и архетипа
function getPsychosocialTypeAndArchetype() {
// Функция для вычисления суммарного шанса в объекте
function getTotalChance(obj) {
return Object.values(obj).reduce((sum, item) => sum + item.chance, 0);
}
// Функция для случайного выбора элемента на основе нормализованных шансов
function getRandomElement(obj, totalChance) {
const rand = Math.random() * totalChance;
let cumulativeChance = 0;
for (const key in obj) {
cumulativeChance += obj[key].chance;
if (rand < cumulativeChance) {
return obj[key];
}
}
}
// Суммарные шансы для каждого набора
const totalTemperamentChance = getTotalChance(temperaments);
const totalPsychosocialChance = getTotalChance(psychosocialTypes);
const totalArchetypeChance = getTotalChance(archetypesAccordingToCarlJung);
// Выбор случайного типа, архетипа и темперамента
const temperament = getRandomElement(temperaments, totalTemperamentChance);
const psychosocialType = getRandomElement(psychosocialTypes, totalPsychosocialChance);
const archetype = getRandomElement(archetypesAccordingToCarlJung, totalArchetypeChance);
return {
temperament: temperament,
psychosocialType: psychosocialType,
archetype: archetype,
};
}
dv.psychoTypes = {
getPsychosocialTypeAndArchetype,
};

View File

@ -0,0 +1,62 @@
# psycho_types.py
import random
TEMPERAMENTS = {
"Sanguine": {"name": "Сангвиник", "chance": 20},
"Choleric": {"name": "Холерик", "chance": 15},
"Melancholic":{"name": "Меланхолик", "chance": 35},
"Phlegmatic": {"name": "Флегматик", "chance": 30},
}
PSYCHOSOCIAL_TYPES = {
"DonKihot": {"name": "Дон Кихот", "chance": 10},
"Duma": {"name": "Дюма", "chance": 8},
"Gyugo": {"name": "Гюго", "chance": 9},
"Robespier": {"name": "Робеспьер", "chance": 7},
"Zhukov": {"name": "Жуков", "chance": 10},
"Yesenin": {"name": "Есенин", "chance": 8},
"Napoleon": {"name": "Наполеон", "chance": 10},
"Balzac": {"name": "Бальзак", "chance": 6},
"JackLondon": {"name": "Джек Лондон", "chance": 10},
"Draizer": {"name": "Драйзер", "chance": 7},
"Shtirlic": {"name": "Штирлиц", "chance": 9},
"Geksli": {"name": "Гексли", "chance": 8},
"Gaben": {"name": "Габен", "chance": 10},
"MaximGorkiy":{"name": "Максим Горький", "chance": 8},
"Hamlet": {"name": "Гамлет", "chance": 9},
"Dostoevskiy":{"name": "Достоевский", "chance": 8},
}
ARCHETYPES = {
"SimpleMinded":{"name": "Простодушный", "chance": 7},
"GoodFellow": {"name": "Славный малый","chance": 8},
"Warrior": {"name": "Воин", "chance": 10},
"Caregiver": {"name": "Опекун", "chance": 9},
"Seeker": {"name": "Искатель", "chance": 10},
"Lover": {"name": "Любовник", "chance": 8},
"Rebel": {"name": "Бунтарь", "chance": 7},
"Creator": {"name": "Творец", "chance": 9},
"Ruler": {"name": "Правитель", "chance": 10},
"Magician": {"name": "Маг", "chance": 6},
"Sage": {"name": "Мудрец", "chance": 9},
"Jester": {"name": "Шут", "chance": 7},
}
def _pick(pool: dict) -> dict:
total = sum(v["chance"] for v in pool.values())
r = random.random() * total
cumulative = 0.0
for item in pool.values():
cumulative += item["chance"]
if r < cumulative:
return item
return list(pool.values())[-1]
def get_psychosocial_type_and_archetype() -> dict:
return {
"temperament": _pick(TEMPERAMENTS),
"psychosocialType":_pick(PSYCHOSOCIAL_TYPES),
"archetype": _pick(ARCHETYPES),
}

781
generate_npc/races.js Normal file
View File

@ -0,0 +1,781 @@
// TODO: расширить список имён
// races (reference: Waterdeep population)
let humanChance = 54.70;
let elfChance = 10.00;
let dwarfChance = 10.00;
let halflingChance = 3.00;
let halfElfChance = 6.00;
let gnomeChance = 2.00;
let halfOrcChance = 3.00;
let orcChance = 2.00;
let tieflingChance = 1.00;
let dragonbornChance = 1.00;
let goblinChance = 1.00;
let hobgoblinChance = 0.50;
let bugbearChance = 0.50;
let koboldChance = 0.50;
let lizardfolkChance = 0.50;
let yuanTiPurebloodChance = 0.10;
let goliathChance = 0.50;
let firbolgChance = 0.20;
let aasimarChance = 1.00;
let genasiChance = 1.00;
let kenkuChance = 0.50;
let tabaxiChance = 0.50;
let leoninChance = 0.10;
let satyrChance = 0.10;
let tritonChance = 0.10;
let githChance = 0.20;
let sunElfChance = 25.00;
let moonElfChance = 34.50;
let woodElfChance = 25.00;
let drowChance = 10.00;
let shadarKaiChance = 2.50;
let eladrinChance = 2.50;
let avarielChance = 0.50;
let halfSunElfChance = 10.00;
let halfMoonElfChance = 56.50;
let halfWoodElfChance = 25.00;
let halfDrowChance = 3.50;
let halfShadarKaiChance = 2.50;
let halfEladrinChance = 2.50;
let hillDwarfChance = 50.00;
let mountainDwarfChance = 40.00;
let duergarChance = 10.00;
let lightfootHalflingChance = 60.00;
let stoutHalflingChance = 30.00;
let ghostwiseHalflingChance = 10.00;
let calishiteChance = 20.00;
let ChondathanChance = 20.00;
let damaranChance = 15.00;
let illuskanChance = 15.00;
let mulanChance = 10.00;
let rashemiChance = 7.50;
let tethyrianChance =7.50;
let shouChance = 2.50;
let koryoanChance = 2.50;
let protectorChance = 50.00;
let scourgeChance = 30.00;
let fallenChance = 20.00;
let airGenasiChance = 25.00;
let earthGenasiChance = 25.00;
let fireGenasiChance = 25.00;
let waterGenasiChance = 25.00;
let dragonbornSubraces = {
"Black": {
"chance": 10.00,
},
"Blue": {
"chance": 10.00,
},
"Brass": {
"chance": 10.00,
},
"Bronze": {
"chance": 10.00,
},
"Copper": {
"chance": 10.00,
},
"Gold": {
"chance": 10.00,
},
"Green": {
"chance": 10.00,
},
"Red": {
"chance": 10.00,
},
"Silver": {
"chance": 10.00,
},
"White": {
"chance": 10.00,
}
};
let races = {
"Aasimar": {
"chance": aasimarChance,
"subraces": {
"Protector": {
"chance": protectorChance
},
"Scourge": {
"chance": scourgeChance
},
"Fallen": {
"chance": fallenChance
}
},
"names": "Human",
"languages": [dv.languages.Common, dv.languages.Celestial]
},
"Bugbear": {
"chance": bugbearChance,
"names": "Goblin",
"languages": [dv.languages.Common, dv.languages.GoblinLanguage]
},
"Dragonborn": {
"chance": dragonbornChance,
"subraces": dragonbornSubraces,
"names": {
"M": ["Арджхан", "Баласар", "Бхараш", "Гхеш", "Донаар", "Крив", "Медраш", "Мехен", "Надарр", "Панджед", "Патрин", "Рхогар", "Тархун", "Торинн", "Хескан", "Шамаш", "Шединн"],
"F": ["Акра", "Бири", "Даар", "Джхери", "Кава", "Коринн", "Мисханн", "Нала", "Перра", "Райанн", "Сора", "Сурина", "Тхава", "Уаджит", "Фаридэ", "Хавилар", "Харанн"]
},
"languages": [dv.languages.Common, dv.languages.Draconic]
},
"Dwarf": {
"chance": dwarfChance,
"subraces": {
"Gold/Hill": {
"chance": hillDwarfChance,
"languages": [dv.languages.Common, dv.languages.Dwarvish]
},
"Shield/Mountain": {
"chance": mountainDwarfChance,
"languages": [dv.languages.Common, dv.languages.Dwarvish]
},
"Duergar": {
"chance": duergarChance,
"languages": [dv.languages.Common, dv.languages.Dwarvish, dv.languages.Undercommon]
}
},
"names": {
"M": ["Адрик", "Альберих", "Баренд", "Барундар", "Баэрн", "Броттор", "Бруенор", "Вондал", "Вулгар", "Вэйт", "Гардаин", "Даин", "Даррак", "Делг", "Джойин", "Дорн", "Килдрак", "Моргран", "Орсик", "Оскар", "Рангрим", "Рорин", "Рюрик", "Сторн", "Таклинн", "Торадин", "Тордек", "Торин", "Торик", "Травок", "Траубон", "Ульфгар", "Фаргрим", "Флинт", "Харбек", "Хондар", "Эберк", "Эйнкиль"],
"F": ["Артин", "Бардрин", "Белмара", "Вистра", "Гуннлода", "Гурдис", "Дагнал", "Джойлин", "Диеза", "Дорна", "Илде", "Катра", "Киира", "Кристид", "Лифтраса", "Мардред", "Одхильд", "Рисвин", "Самбрил", "Саннл", "Тейс", "Торбера", "Торгга", "Умиль", "Фалкрунн", "Финеллен", "Хельджа", "Хлин", "Эльдет", "Эмбер"],
"S": ["Балдерк", "Блейтбайт", "Боевой Молот", "Гордривер", "Горунн", "Данкил", "Железный Кулак", "Каменный Щит", "Крауншилд", "Крепкая Наковальня", "Ледяная Борода", "Лодерр", "Лютгер", "Огненная Кузня", "Рамнахейм", "Рог", "Скуллдарк", "Стракелн", "Торунн", "Унгарт", "Холдерхек"]
},
"languages": [dv.languages.Common, dv.languages.Dwarvish]
},
"Elf": {
"chance": elfChance,
"subraces": {
"Avariel": {
"chance": avarielChance,
"names": "Moon",
"languages": [dv.languages.Common, dv.languages.Elvish]
},
"Drow": {
"chance": drowChance,
"names": {
"M": ["Алак", "Дриззт", "Ильмрин", "Меринид", "Фарон", "Риззон", "Тебрин", "Закнафейн"],
"F": ["Акордия", "Чалитра", "Эклавдра", "Джаэлрина", "Недилен", "Квилуэ", "СиНафай, Влондрил"],
"S": ["Абафейр", "Колоара", "Гланнат", "Иллистин", "Фарн", "Сиреар", "Вринн", "Ксилтин"]
},
"languages": [dv.languages.Common, dv.languages.Elvish, dv.languages.Undercommon]
},
"Eladrin": {
"chance": eladrinChance,
"names": "Moon",
"languages": [dv.languages.Common, dv.languages.Elvish]
},
"Moon": {
"chance": moonElfChance,
"names": {
"M": ["Адран", "Аравилар", "Арамиль", "Араннис", "Ауст", "Аэлар", "Бейро", "Берриан", "Варис", "Галинндан", "Ивеллиос", "Иммераль", "Каррик", "Куарион", "Лаусиан", "Миндартис", "Морн", "Ним", "Орлпар", "Паэлиас", "Перен", "Респен", "Риардон", "Ристель", "Ролен", "Саэвел", "Совелисс", "Тамиорн", "Таривол", "Терен", "Тэген", "Фейлар", "Хадарай", "Химо", "Хэйян", "Эниалис", "Эрдан", "Эреван"],
"F": ["Адрие", "Альтеа", "Амра", "Анастрианна", "Андрасте", "Антинуа", "Бетринна", "Бирель", "Вадания", "Валанте", "Вестеле", "Джаумрит", "Джеленетт", "Друсилиа", "Имизаэль", "Йелениа", "Каэлинн", "Квамара", "Квеленна", "Квиласи", "Кейлет", "Ксанафия", "Лешанна", "Лиа", "Миали", "Мэриэль", "Найвара", "Сариэль", "Силакви", "Талиндра", "Теирастра", "Тиа", "Фелосиаль", "Хакатра", "Шава", "Шанайра", "Энна", "Ястра"],
"S": ["Амакиир (Сверкающий Цветок)", "Амалит", "Амастасия (Звёздный Цветок)", "Брэген", "Галанодель (Лунный Шёпот)", "Ивнингфолл", "Ильфелкиир (Сверкающий Бутон)", "Калаудра", "Ксилосент (Золотой Лепесток)", "Лаэлитар", "Лиадон (Серебряный Лист)", "Мундаун", "Найло (Ночной Бриз)", "Сианодель (Лунный Ручей)", "Тарнрут", "Холимион (Алмазная Роса)"]
},
"languages": [dv.languages.Common, dv.languages.Elvish]
},
"Shadar-kai": {
"chance": shadarKaiChance,
"names": "Moon",
"languages": [dv.languages.Common, dv.languages.Elvish]
},
"Sun": {
"chance": sunElfChance,
"names": "Moon",
"languages": [dv.languages.Common, dv.languages.Elvish]
},
"Wood": {
"chance": woodElfChance,
"names": "Moon",
"languages": [dv.languages.Common, dv.languages.Elvish]
}
},
"languages": [dv.languages.Common, dv.languages.Elvish]
},
"Firbolg": {
"chance": firbolgChance,
"names": "Elf",
"languages": [dv.languages.Common, dv.languages.Elvish, dv.languages.GiantLanguage]
},
"Genasi": {
"chance": genasiChance,
"subraces": {
"Air": {
"chance": airGenasiChance
},
"Earth": {
"chance": earthGenasiChance
},
"Fire": {
"chance": fireGenasiChance
},
"Water": {
"chance": waterGenasiChance
}
},
"names": "Human",
"languages": [dv.languages.Common, dv.languages.Primordial]
},
"Gith": {
"chance": githChance,
"names": {
"M": ["Al'chaia", "Dak'kon", "Lae'zel", "Vhostym", "Yrlakka"],
"F": ["Al'chaia", "Dak'kon", "Lae'zel", "Vhostym", "Yrlakka"]
},
"languages": [dv.languages.Common, dv.languages.GithLanguage]
},
"Gnome": {
"chance": gnomeChance,
"subraces": {
"Forest": {
"chance": 50.00
},
"Rock": {
"chance": 50.00
}
},
"names": {
"M": ["Алвин", "Алстон", "Боддинок", "Брок", "Бургелл", "Бюргел", "Варрин", "Вренн", "Гербо", "Гимбл", "Глим", "Джебеддо", "Димбл", "Дорган", "Зук", "Келлен", "Колмарр", "Намфудл", "Орламм", "Оррин", "Ронделл", "Рундар", "Сибо", "Синдри", "Столиг", "Фалринн", "Фонкин", "Фрюг", "Халбринн", "Элдон", "Эрки"],
"F": ["Бимпноттин", "Брина", "Вейуокет", "Джари", "Донелла", "Дувамил", "Занна", "Зелазадда", "Ийисс", "Каландра", "Карамип", "Карлин", "Лилли", "Лисса", "Лорилла", "Лупмоттин", "Маднаб", "Мери", "Нати", "Никс", "Нисса", "Ода", "Орла", "Ройвин", "Тана", "Шамил", "Эливик", "Элиджобелл", "Элла", "Эрисс"],
"S": ["Берен", "Блэкрок", "Блимс", "Гаррик", "Грейторм", "Даергел", "Мёрниг", "Накл", "Нингел", "Раулнор", "Ривенстоун", "Тавартарр", "Тимберс", "Турен", "Уайтхорн", "Уварикк", "Фолькор", "Шеппен"]
},
"languages": [dv.languages.Common, dv.languages.GnomishLanguage]
},
"Goblin": {
"chance": goblinChance,
"names": {
"M": ["Аругет", "Вании", "Ванон", "Вуударадж", "Гудруун", "Даавн", "Дабрак", "Дагии", "Джазаал", "Древдуул", "Дуулан", "Каллаад", "Кракуул", "Кроотад", "Мазаан", "Мунта", "Насаар", "Ракари", "Рексиит", "Тарик", "Тарууж", "Туун", "Феник", "Четин", "Халуун", "Харуук", "Аззинакс", "Баболакс", "Бликсаникс", "Вазозав", "Вексин", "Даззаз", "Зиззикс", "Калузакс", "Криксизикс", "Лизакса", "Миззикс", "Мизнар", "Никсиспикс", "Паксизаз", "Равиксиз", "Стиксил", "Сунникс", "Тозинокс", "Уксивози", "Эстрикс", "Финизикс", "Юзба"],
"F": ["Ааспар", "Агуус", "Белалуур", "Вали", "Вуун", "Грааль", "Гадуул", "Денаал", "Джелуум", "Драраар", "Дууша", "Келаал", "Мулаан", "Насри", "Разу", "Ралин", "Рексин", "Сенен", "Таджин", "Тюнер", "Хашак", "Шедруор", "Эхаас", "Элуун"]
},
"languages": [dv.languages.Common, dv.languages.GoblinLanguage]
},
"Goliath": {
"chance": goliathChance,
"names": {
"M": ["Аукан", "Ваунеа", "Вимак", "Гае-Ал", "Иглат", "Иликан", "Кеотхи", "Куори", "Ло-Каг", "Мавейт", "Маннео", "Налла", "Орило", "Пааву", "Петани", "Талай", "Тхотам", "Утхал"]
},
"languages": [dv.languages.Common, dv.languages.GiantLanguage]
},
"Half-Elf": {
"chance": halfElfChance,
"subraces": {
"Drow": {
"chance": halfDrowChance
},
"Eladrin": {
"chance": halfEladrinChance
},
"Moon": {
"chance": halfMoonElfChance
},
"Shadar-kai": {
"chance": halfShadarKaiChance
},
"Sun": {
"chance": halfSunElfChance
},
"Wood": {
"chance": halfWoodElfChance
}
},
"names": ["Human", "Elf"],
"languages": [dv.languages.Common, dv.languages.Elvish, dv.languages.livingLanguagesExceptCommon]
},
"Half-Orc": {
"chance": halfOrcChance,
"names": {
"M": ["Гел", "Денч", "Имш", "Кет", "Краск", "Муррен", "Ронт", "Токк", "Фенг", "Хенк", "Холг", "Шамп"],
"F": ["Багги", "Вола", "Волен", "Евельда", "Кансиф", "Мев", "Нига", "Овак", "Оунка", "Сута", "Шаута", "Эмен", "Энгонг"]
},
"languages": [dv.languages.Common, dv.languages.OrcLanguage]
},
"Halfling": {
"chance": halflingChance,
"subraces": {
"Ghostwise": {
"chance": ghostwiseHalflingChance
},
"Lightfoot": {
"chance": lightfootHalflingChance
},
"Stout": {
"chance": stoutHalflingChance
}
},
"names": {
"M": ["Альтон", "Андер", "Блазанар", "Вилимак", "Гаррет", "Далабрак", "Кейд", "Коррин", "Коркаури", "Лайл", "Линдал", "Майло", "Меррик", "Омберт", "Осборн", "Перрин", "Рид", "Роберк", "Роско", "Тирраури", "Уэллби", "Финнан", "Халандар", "Элдон", "Эррих"],
"F": ["Алонирра", "Бри", "Вани", "Венинда", "Верна", "Делдира", "Джиллиан", "Калатра", "Китри", "Кора", "Кэлли", "Лавиния", "Лидда", "Мелинден", "Мерла", "Недда", "Олпара", "Паэла", "Портия", "Росинден", "Серафина", "Тара", "Трим", "Шаэна", "Эндри", "Юфемия"],
"S": ["Аумбль", "Брамблфут", "Вверхтормашкин", "Высокохолм", "Галькоброс", "Дардаргон", "Добробочка", "Зеленофляг", "Кустосбор", "Лугодуг", "Мерримар", "Подветкин", "Репейник", "Старнхап", "Хардингдейл", "Чайнолист"]
},
"languages": [dv.languages.Common, dv.languages.HalflingLanguage]
},
"Hobgoblin": {
"chance": hobgoblinChance,
"names": "Goblin",
"languages": [dv.languages.Common, dv.languages.GoblinLanguage]
},
"Human": {
"chance": humanChance,
"subraces": {
"Calishite": {
"chance": calishiteChance,
"names": {
"M": ["Асеир", "Бардеид", "Зашеир", "Кхемед", "Мехмен", "Судейман", "Хасеид"],
"F": ["Атала", "Джасмаль", "Зашеида", "Мейлиль", "Сейдиль", "Сейпора", "Хама", "Яшеира"],
"S": ["Баша", "Джассан", "Думеин", "Кхалид", "Мостана", "Пашар", "Рейн"]
},
"languages": [dv.languages.Common, dv.languages.Alzhedo]
},
"Chondathan": {
"chance": ChondathanChance,
"names": {
"M": ["Горстаг", "Грим", "Дарвин", "Дорн", "Маларк", "Морн", "Рэндал", "Стедд", "Хельм", "Эвендур"],
"F": ["Арвин", "Джессаиль", "Керри", "Лурин", "Мири", "Рован", "Тесселе", "Шандри", "Эсвель"],
"S": ["Бакмэн", "Грэйкасл", "Дандрэгон", "Толстаг", "Эвенвуд", "Эмблкроун"]
},
"languages": [dv.languages.Common, dv.languages.Chondathan]
},
"Damaran": {
"chance": damaranChance,
"names": {
"M": ["Айвор", "Бор", "Глэр", "Григор", "Иган", "Козеф", "Миваль", "Орел", "Павел", "Сергор", "Фодель"],
"F": ["Алетра", "Зора", "Кара", "Катернин", "Мара", "Натали", "Ольма", "Тана"],
"S": ["Берск", "Дотск", "Куленов", "Марск", "Немецк", "Стараг", "Чернин", "Шемов"]
},
"languages": [dv.languages.Common, dv.languages.Damaran]
},
"Illuskan": {
"chance": illuskanChance,
"names": {
"M": ["Блас", "Бран", "Гет", "Ландер", "Лют", "Малсер", "Стор", "Таман", "Урт", "Фрат", "Эндер"],
"F": ["Амафрея", "Бетха", "Вестра", "Кетра", "Мара", "Ольга", "Силифрей", "Цефрея"],
"S": ["Брайтвуд", "Виндривер", "Лакмэн", "Хелдер", "Хорнрейвен", "Штормвинд"]
},
"languages": [dv.languages.Common, dv.languages.Illuskan]
},
"Koryoan": {
"chance": koryoanChance,
"names": "Shou",
"languages": [dv.languages.Common, dv.languages.KoryoLanguage]
},
"Mulan": {
"chance": mulanChance,
"names": {
"M": ["Аот", "Барерис", "Кетот", "Мумед", "Рамас", "Со-Кехур", "Тхазар-Де", "Урхур", "ЭхпутКи"],
"F": ["Аризима", "Золис", "Мурити", "Нефис", "Нулара", "Сефрис", "Тола", "Умара", "Чатхи"],
"S": ["Анкхалаб", "Анскульд", "Натандем", "Серпет", "Уутракт", "Фезим", "Хахпет"]
},
"languages": [dv.languages.Common, dv.languages.Mulani]
},
"Rashemi": {
"chance": rashemiChance,
"names": {
"M": ["Боривик", "Владислак", "Джандар", "Канитар", "Мадислак", "Ральмевик", "Фаургар", "Шаумар"],
"F": ["Имзель", "Иммит", "Наварра", "Таммит", "Файварра", "Хульмарра", "Шеварра", "Юльдра"],
"S": ["Дайрнина", "Илтазяра", "Мурнитара", "Стаянога", "Улмокина", "Чергоба"]
},
"languages": [dv.languages.Common, dv.languages.Rashemi]
},
"Shou": {
"chance": shouChance,
"names": {
"M": ["Ан", "Вэнь", "Лонг", "Лянь", "Менг", "Он", "Фай", "Цзюн", "Цзянь", "Чен", "Чи", "Шань", "Шуй"],
"F": ["Бай", "Ксяо", "Лей", "Мей", "Тай", "Цзя", "Чао", "Шуй"],
"S": ["Вань", "Као", "Кунг", "Лао", "Линг", "Мей", "Пинь", "Сум", "Тань", "Хуан", "Чиень", "Шин"]
},
"languages": [dv.languages.Common, dv.languages.Shou]
},
"Tethyrian": {
"chance": tethyrianChance,
"names": "Chondathan",
"languages": [dv.languages.Common, dv.languages.Chondathan]
}
}
},
"Kenku": {
"chance": kenkuChance,
"names": {
"M": ["Разбиватель", "Лязгун", "Разрезатель", "Грохотун", "Крысиный Скрежет", "Свистун", "Мышатник", "Рычун"],
"F": ["Свистун", "Мышатник", "Рычун"]
},
"languages": [dv.languages.Common, dv.languages.Primordial]
},
"Kobold": {
"chance": koboldChance,
"names": [],
"languages": [dv.languages.Common, dv.languages.Draconic]
},
"Leonin": {
"chance": leoninChance,
"names": {
"M": ["Апто", "Ат", "Барагон", "Брыг", "Горий", "Грекс", "Земн", "Зиор", "Орий", "Пиксатор", "Тей", "Эрем"],
"F": ["Алета", "Атагона", "Грагонда", "Демна", "Доксия", "Зиора", "Иадма", "Койла", "Орамна", "Сеза", "Эката", "Эриз"]
},
"languages": [dv.languages.Common, dv.languages.LeoninLanguage]
},
"Lizardfolk": {
"chance": lizardfolkChance,
"names": [],
"languages": [dv.languages.Common, dv.languages.Draconic]
},
"Orc": {
"chance": orcChance,
"names": {
"M": ["Абзуг", "Бажок", "Беск", "Готог", "Груток", "Джахрук", "Дурт", "Кеск", "Лортар", "Мобад", "Мугруб", "Орруск", "Рук", "Рхог", "Тараг", "Тог", "Угурт", "Урзул", "Фанг", "Харл", "Шамок", "Шугог"],
"F": ["Багги", "Бетарра", "Варра", "Вола", "Волен", "Дуваега", "Имен", "Йескарра", "Кансиф", "Креске", "Муай", "Неске", "Ниига", "Овак", "Орваега", "Оунка", "Сута", "Шаута", "Эдарреске", "Энгонг"],
"S": ["Думник", "Хортор", "Ламмар", "Сормужик", "Турнчерпал", "Улькруннар", "Зоргар"]
},
"languages": [dv.languages.Common, dv.languages.OrcLanguage]
},
"Satyr": {
"chance": satyrChance,
"names": [],
"languages": [dv.languages.Common, dv.languages.Sylvan]
},
"Tabaxi": {
"chance": tabaxiChance,
"names": [],
"languages": [dv.languages.Common, dv.languages.livingLanguagesExceptCommon]
},
"Tiefling": {
"chance": tieflingChance,
"names": {
"M": ["Акменос", "Амнон", "Баракас", "Дамакос", "Йадос", "Кайрон", "Люцис", "Мелех", "Мордай", "Мортос", "Пелайос", "Скамос", "Терай", "Экемон"],
"F": ["Акта", "Анакис", "Брисеис", "Дамая", "Каллиста", "Криелла", "Лерисса", "Макария", "Немея", "Орианна", "Риета", "Фелая", "Эа"],
"S": ["Амезис", "Барбатос", "Дракос", "Инфернус", "Мефисто", "Фирнас", "Хекатон", "Харгрейв", "Эбони"]
},
"languages": [dv.languages.Common, dv.languages.Infernal]
},
"Triton": {
"chance": tritonChance,
"names": {
"M": ["Водос", "Делнис", "Джимас", "Зунис", "Керос", "Корус", "Молос", "Налос"],
"F": ["Белтин", "Вуолин", "Влэрин", "Дьютин", "Отанин", "Филорин", "Шелрин", "Эрин"]
},
"languages": [dv.languages.Common, dv.languages.Primordial]
},
"Yuan-ti Pureblood": {
"chance": yuanTiPurebloodChance,
"names": [],
"languages": [dv.languages.Common, dv.languages.Abyssal, dv.languages.Draconic]
}
};
function selectRandomRaceAndSubrace(racesDict) {
// Получаем список рас
let raceNames = Object.keys(racesDict);
// Вычисляем общую сумму шансов рас
let totalRaceChance = 0;
for (let race of raceNames) {
totalRaceChance += racesDict[race].chance;
}
// Генерируем случайное число для выбора расы
let randomRaceChance = Math.random() * totalRaceChance;
// Выбираем расу
let accumulatedRaceChance = 0;
let selectedRace;
for (let race of raceNames) {
accumulatedRaceChance += racesDict[race].chance;
if (randomRaceChance <= accumulatedRaceChance) {
selectedRace = race;
break;
}
}
// Проверяем наличие подрас
let subracesDict = racesDict[selectedRace].subraces;
let selectedSubrace;
if (subracesDict && Object.keys(subracesDict).length > 0) {
// Есть подрасы, выбираем подрасу
let subraceNames = Object.keys(subracesDict);
// Вычисляем общую сумму шансов подрас
let totalSubraceChance = 0;
for (let subrace of subraceNames) {
totalSubraceChance += subracesDict[subrace].chance;
}
// Генерируем случайное число для выбора подрасы
let randomSubraceChance = Math.random() * totalSubraceChance;
// Выбираем подрасу
let accumulatedSubraceChance = 0;
for (let subrace of subraceNames) {
accumulatedSubraceChance += subracesDict[subrace].chance;
if (randomSubraceChance <= accumulatedSubraceChance) {
selectedSubrace = subrace;
break;
}
}
} else {
// Подрас нет, подраса совпадает с расой
selectedSubrace = null;
}
// Формируем результат
if (selectedSubrace) {
return {
race: selectedRace,
subrace: selectedSubrace
};
} else {
return {
race: selectedRace
};
}
}
// Функция для получения имени на основе расы, подрасы и пола
function getName(raceObj, gender) {
const genderKey = gender.toUpperCase(); // 'M' или 'F'
let raceKey = raceObj.race;
let subraceKey = raceObj.subrace;
// Функция для разрешения имен
function resolveNames(racesDict, raceKey, subraceKey) {
let nameData = null;
let keysToProcess = [{ raceKey, subraceKey }];
let nameSource = null; // Переменная для хранения последнего источника имен
while (keysToProcess.length > 0) {
let { raceKey, subraceKey } = keysToProcess.shift();
let raceData = racesDict[raceKey];
if (!raceData) continue;
let names = null;
// Проверяем наличие подрасы и имен в подрасе
let raceRef = false;
if (
subraceKey &&
raceData.subraces &&
raceData.subraces[subraceKey] &&
raceData.subraces[subraceKey].names
) {
// указана подраса, она действительно есть в расе, и в ней есть имена
names = raceData.subraces[subraceKey].names;
} else { // Например, кейс, когда перепрыгнули на расу без указания подрасы, а на уровне расы имён нет
// подрасы в json должны ссылаться только на другие подрасы той же расы, иначе скрипт надо дорабатывать, т.к. здесь получим бесконечный цикл
// если в расе есть подрасы, то нужно выбрать произвольную внутри и её имена
if (raceData.subraces && Object.keys(raceData.subraces).length > 0) {
let subraceKeys = Object.keys(raceData.subraces);
// Выбираем случайную подрасу
let randomSubraceKey = subraceKeys[Math.floor(Math.random() * subraceKeys.length)];
let subraceData = raceData.subraces[randomSubraceKey];
// Проверяем, есть ли имена в этой подрасе
if (subraceData.names) {
names = subraceData.names;
subraceKey = randomSubraceKey; // обновляем subraceKey
} else {
// Имена в подрасе отсутствуют, используем имена на уровне расы
names = raceData.names;
raceRef = true;
}
}
else {
// иначе - имена на уровне расы
names = raceData.names;
raceRef = true;
}
}
if (!names) continue;
let finalData = null;
if (typeof names === 'string' || Array.isArray(names)) {
// Если names - строка или массив ссылок
let reference;
if (typeof names === 'string') {
reference = names;
} else {
// Если это массив ссылок, выбираем случайный элемент
let randomIndex = Math.floor(Math.random() * names.length);
reference = names[randomIndex];
}
if (raceRef) {
// Ссылка на другую расу
keysToProcess.push({ raceKey: reference, subraceKey: null });
} else {
// Ссылка на другую подрасу
keysToProcess.push({ raceKey: raceKey, subraceKey: reference });
}
continue;
} else if (typeof names === 'object') {
// Если имена - объект
const keysToCheck = ['M', 'F', 'S'];
if (keysToCheck.some(key => names.hasOwnProperty(key))) {
// Имена непосредственно содержатся в объекте
finalData = names;
// Запоминаем источник имен
nameSource = { raceKey, subraceKey };
}
}
if (!finalData) continue;
// Объединяем данные имен
if (!nameData) {
nameData = { M: [], F: [], S: [] };
}
['M', 'F', 'S'].forEach(gender => {
if (finalData[gender]) {
nameData[gender] = nameData[gender].concat(finalData[gender]);
}
});
}
return { nameData, nameSource };
}
// Получаем данные имен
let { nameData, nameSource } = resolveNames(races, raceKey, subraceKey);
// Если имена не найдены, возвращаем пустую строку
if (!nameData) return '';
// Получаем список имен в зависимости от пола
let firstNameList = nameData[genderKey];
if (!firstNameList || firstNameList.length === 0) {
// Используем имена другого пола, если своих нет
firstNameList = genderKey === 'M' ? nameData['F'] : nameData['M'];
}
// Если имен нет вообще, возвращаем пустую строку
if (!firstNameList || firstNameList.length === 0) return '';
// Получаем список фамилий
let surnameList = nameData['S'];
// Выбираем случайное имя и фамилию
let firstName = firstNameList[Math.floor(Math.random() * firstNameList.length)];
let surname = '';
if (surnameList && surnameList.length > 0) {
surname = surnameList[Math.floor(Math.random() * surnameList.length)];
}
// Возвращаем полное имя, а также расу и подрасу, которой принадлежит данное имя
return {
name: `${firstName} ${surname}`.trim(),
race: nameSource.raceKey,
subrace: nameSource.subraceKey
};
}
// Функция для получения списка языков на основе расы и подрасы
function getLanguages(raceObj, fullNameObj) {
let raceKey = raceObj.race;
let subraceKey = raceObj.subrace;
let languages = [];
// Вспомогательная функция для получения всех языков для заданной расы и подрасы
function getAllLanguagesForRace(raceKey, subraceKey) {
let raceData = races[raceKey];
if (!raceData) return [];
let languagesList = [];
// Проверяем наличие подрасы и языков в подрасе
if (
subraceKey &&
raceData.subraces &&
raceData.subraces[subraceKey] &&
raceData.subraces[subraceKey].languages
) {
languagesList = raceData.subraces[subraceKey].languages;
} else {
// Используем языки на уровне расы
languagesList = raceData.languages;
}
if (!languagesList) return [];
return languagesList;
}
// Получаем список языков для текущей расы и подрасы
let languagesList = getAllLanguagesForRace(raceKey, subraceKey);
// Обрабатываем каждый элемент в languagesList
languagesList.forEach(languageItem => {
if (Array.isArray(languageItem)) {
// Если элемент - массив, применяем особую обработку
if (fullNameObj.race === raceKey && fullNameObj.subrace === subraceKey) {
// Расы и подрасы совпадают, выбираем случайный язык из массива
let randomIndex = Math.floor(Math.random() * languageItem.length);
let selectedLanguage = languageItem[randomIndex];
// Добавляем язык в итоговый список
languages.push(selectedLanguage);
} else {
// дальше дикий костыль
// Расы и/или подрасы различаются, берем языки из fullNameObj
let fullNameLanguages = getAllLanguagesForRace(fullNameObj.race, fullNameObj.subrace);
// Разворачиваем список языков
let flatLanguages = [];
fullNameLanguages.forEach(languageItem => {
if (Array.isArray(languageItem)) {
// Если элемент - массив, добавляем все его элементы
flatLanguages = flatLanguages.concat(languageItem);
} else {
flatLanguages.push(languageItem);
}
});
fullNameLanguages = flatLanguages;
// Удаляем уже выбранные языки
let availableLanguages = fullNameLanguages.filter(lang => !languages.includes(lang));
if (availableLanguages.length > 0) {
// Выбираем случайный язык из доступных
let randomIndex = Math.floor(Math.random() * availableLanguages.length);
let selectedLanguage = availableLanguages[randomIndex];
// Добавляем язык в итоговый список
languages.push(selectedLanguage);
}
// Если нет доступных языков, ничего не делаем
}
} else {
// Если язык - простой элемент, добавляем его
languages.push(languageItem);
}
});
// Возвращаем уникальный список языков
return [...new Set(languages)];
}
dv.races = {
races,
selectRandomRaceAndSubrace,
getName,
getLanguages
}

492
generate_npc/races.py Normal file
View File

@ -0,0 +1,492 @@
# races.py
import random
from languages import (
Common, Celestial, Elvish, Dwarvish, Draconic, GiantLanguage,
GnomishLanguage, GoblinLanguage, HalflingLanguage, OrcLanguage,
Infernal, Primordial, Sylvan, Abyssal, GithLanguage, LeoninLanguage,
Undercommon, Alzhedo, Chondathan, Damaran, Illuskan, KoryoLanguage,
Mulani, Rashemi, Shou,
living_languages_except_common,
)
# ─── Расы ────────────────────────────────────────────────────────────────────
RACES: dict = {
"Aasimar": {
"chance": 1.00,
"subraces": {
"Protector": {"chance": 50.00},
"Scourge": {"chance": 30.00},
"Fallen": {"chance": 20.00},
},
"names": "Human",
"languages": [Common, Celestial],
},
"Bugbear": {
"chance": 0.50,
"names": "Goblin",
"languages": [Common, GoblinLanguage],
},
"Dragonborn": {
"chance": 1.00,
"subraces": {color: {"chance": 10.00} for color in
["Black","Blue","Brass","Bronze","Copper",
"Gold","Green","Red","Silver","White"]},
"names": {
"M": ["Арджхан","Баласар","Бхараш","Гхеш","Донаар","Крив","Медраш","Мехен",
"Надарр","Панджед","Патрин","Рхогар","Тархун","Торинн","Хескан","Шамаш","Шединн"],
"F": ["Акра","Бири","Даар","Джхери","Кава","Коринн","Мисханн","Нала",
"Перра","Райанн","Сора","Сурина","Тхава","Уаджит","Фаридэ","Хавилар","Харанн"],
},
"languages": [Common, Draconic],
},
"Dwarf": {
"chance": 10.00,
"subraces": {
"Gold/Hill": {"chance": 50.00, "languages": [Common, Dwarvish]},
"Shield/Mountain":{"chance": 40.00, "languages": [Common, Dwarvish]},
"Duergar": {"chance": 10.00, "languages": [Common, Dwarvish, Undercommon]},
},
"names": {
"M": ["Адрик","Альберих","Баренд","Барундар","Баэрн","Броттор","Бруенор","Вондал",
"Вулгар","Вэйт","Гардаин","Даин","Даррак","Делг","Джойин","Дорн","Килдрак",
"Моргран","Орсик","Оскар","Рангрим","Рорин","Рюрик","Сторн","Таклинн",
"Торадин","Тордек","Торин","Торик","Травок","Траубон","Ульфгар","Фаргрим",
"Флинт","Харбек","Хондар","Эберк","Эйнкиль"],
"F": ["Артин","Бардрин","Белмара","Вистра","Гуннлода","Гурдис","Дагнал","Джойлин",
"Диеза","Дорна","Илде","Катра","Киира","Кристид","Лифтраса","Мардред",
"Одхильд","Рисвин","Самбрил","Саннл","Тейс","Торбера","Торгга","Умиль",
"Фалкрунн","Финеллен","Хельджа","Хлин","Эльдет","Эмбер"],
"S": ["Балдерк","Блейтбайт","Боевой Молот","Гордривер","Горунн","Данкил",
"Железный Кулак","Каменный Щит","Крауншилд","Крепкая Наковальня",
"Ледяная Борода","Лодерр","Лютгер","Огненная Кузня","Рамнахейм",
"Рог","Скуллдарк","Стракелн","Торунн","Унгарт","Холдерхек"],
},
"languages": [Common, Dwarvish],
},
"Elf": {
"chance": 10.00,
"subraces": {
"Avariel": {"chance": 0.50, "names": "Moon", "languages": [Common, Elvish]},
"Drow": {
"chance": 10.00,
"names": {
"M": ["Алак","Дриззт","Ильмрин","Меринид","Фарон","Риззон","Тебрин","Закнафейн"],
"F": ["Акордия","Чалитра","Эклавдра","Джаэлрина","Недилен","Квилуэ","СиНафай","Влондрил"],
"S": ["Абафейр","Колоара","Гланнат","Иллистин","Фарн","Сиреар","Вринн","Ксилтин"],
},
"languages": [Common, Elvish, Undercommon],
},
"Eladrin": {"chance": 2.50, "names": "Moon", "languages": [Common, Elvish]},
"Moon": {
"chance": 34.50,
"names": {
"M": ["Адран","Аравилар","Арамиль","Араннис","Ауст","Аэлар","Бейро","Берриан",
"Варис","Галинндан","Ивеллиос","Иммераль","Каррик","Куарион","Лаусиан",
"Миндартис","Морн","Ним","Орлпар","Паэлиас","Перен","Респен","Риардон",
"Ристель","Ролен","Саэвел","Совелисс","Тамиорн","Таривол","Терен",
"Тэген","Фейлар","Хадарай","Химо","Хэйян","Эниалис","Эрдан","Эреван"],
"F": ["Адрие","Альтеа","Амра","Анастрианна","Андрасте","Антинуа","Бетринна",
"Бирель","Вадания","Валанте","Вестеле","Джаумрит","Джеленетт","Друсилиа",
"Имизаэль","Йелениа","Каэлинн","Квамара","Квеленна","Квиласи","Кейлет",
"Ксанафия","Лешанна","Лиа","Миали","Мэриэль","Найвара","Сариэль","Силакви",
"Талиндра","Теирастра","Тиа","Фелосиаль","Хакатра","Шава","Шанайра","Энна","Ястра"],
"S": ["Амакиир (Сверкающий Цветок)","Амалит","Амастасия (Звёздный Цветок)",
"Брэген","Галанодель (Лунный Шёпот)","Ивнингфолл","Ильфелкиир (Сверкающий Бутон)",
"Калаудра","Ксилосент (Золотой Лепесток)","Лаэлитар","Лиадон (Серебряный Лист)",
"Мундаун","Найло (Ночной Бриз)","Сианодель (Лунный Ручей)","Тарнрут",
"Холимион (Алмазная Роса)"],
},
"languages": [Common, Elvish],
},
"Shadar-kai":{"chance": 2.50, "names": "Moon", "languages": [Common, Elvish]},
"Sun": {"chance": 25.00, "names": "Moon", "languages": [Common, Elvish]},
"Wood": {"chance": 25.00, "names": "Moon", "languages": [Common, Elvish]},
},
"languages": [Common, Elvish],
},
"Firbolg": {
"chance": 0.20,
"names": "Elf",
"languages": [Common, Elvish, GiantLanguage],
},
"Genasi": {
"chance": 1.00,
"subraces": {
"Air": {"chance": 25.00},
"Earth": {"chance": 25.00},
"Fire": {"chance": 25.00},
"Water": {"chance": 25.00},
},
"names": "Human",
"languages": [Common, Primordial],
},
"Gith": {
"chance": 0.20,
"names": {
"M": ["Al'chaia","Dak'kon","Lae'zel","Vhostym","Yrlakka"],
"F": ["Al'chaia","Dak'kon","Lae'zel","Vhostym","Yrlakka"],
},
"languages": [Common, GithLanguage],
},
"Gnome": {
"chance": 2.00,
"subraces": {
"Forest": {"chance": 50.00},
"Rock": {"chance": 50.00},
},
"names": {
"M": ["Алвин","Алстон","Боддинок","Брок","Бургелл","Бюргел","Варрин","Вренн","Гербо",
"Гимбл","Глим","Джебеддо","Димбл","Дорган","Зук","Келлен","Колмарр","Намфудл",
"Орламм","Оррин","Ронделл","Рундар","Сибо","Синдри","Столиг","Фалринн","Фонкин",
"Фрюг","Халбринн","Элдон","Эрки"],
"F": ["Бимпноттин","Брина","Вейуокет","Джари","Донелла","Дувамил","Занна","Зелазадда",
"Ийисс","Каландра","Карамип","Карлин","Лилли","Лисса","Лорилла","Лупмоттин",
"Маднаб","Мери","Нати","Никс","Нисса","Ода","Орла","Ройвин","Тана","Шамил",
"Эливик","Элиджобелл","Элла","Эрисс"],
"S": ["Берен","Блэкрок","Блимс","Гаррик","Грейторм","Даергел","Мёрниг","Накл",
"Нингел","Раулнор","Ривенстоун","Тавартарр","Тимберс","Турен","Уайтхорн",
"Уварикк","Фолькор","Шеппен"],
},
"languages": [Common, GnomishLanguage],
},
"Goblin": {
"chance": 1.00,
"names": {
"M": ["Аругет","Вании","Ванон","Вуударадж","Гудруун","Даавн","Дабрак","Дагии",
"Джазаал","Древдуул","Дуулан","Каллаад","Кракуул","Кроотад","Мазаан","Мунта",
"Насаар","Ракари","Рексиит","Тарик","Тарууж","Туун","Феник","Четин","Халуун",
"Харуук","Аззинакс","Баболакс","Бликсаникс","Вазозав","Вексин","Даззаз",
"Зиззикс","Калузакс","Криксизикс","Лизакса","Миззикс","Мизнар","Никсиспикс",
"Паксизаз","Равиксиз","Стиксил","Сунникс","Тозинокс","Уксивози","Эстрикс",
"Финизикс","Юзба"],
"F": ["Ааспар","Агуус","Белалуур","Вали","Вуун","Грааль","Гадуул","Денаал","Джелуум",
"Драраар","Дууша","Келаал","Мулаан","Насри","Разу","Ралин","Рексин","Сенен",
"Таджин","Тюнер","Хашак","Шедруор","Эхаас","Элуун"],
},
"languages": [Common, GoblinLanguage],
},
"Goliath": {
"chance": 0.50,
"names": {
"M": ["Аукан","Ваунеа","Вимак","Гае-Ал","Иглат","Иликан","Кеотхи","Куори",
"Ло-Каг","Мавейт","Маннео","Налла","Орило","Пааву","Петани","Талай","Тхотам","Утхал"],
"F": ["Аукан","Ваунеа","Вимак","Гае-Ал","Иглат","Иликан","Кеотхи","Куори",
"Ло-Каг","Мавейт","Маннео","Налла","Орило","Пааву","Петани","Талай","Тхотам","Утхал"],
},
"languages": [Common, GiantLanguage],
},
"Half-Elf": {
"chance": 6.00,
"subraces": {
"Drow": {"chance": 3.50},
"Eladrin": {"chance": 2.50},
"Moon": {"chance": 56.50},
"Shadar-kai":{"chance": 2.50},
"Sun": {"chance": 10.00},
"Wood": {"chance": 25.00},
},
"names": ["Human", "Elf"],
"languages": [Common, Elvish, living_languages_except_common],
},
"Half-Orc": {
"chance": 3.00,
"names": {
"M": ["Гел","Денч","Имш","Кет","Краск","Муррен","Ронт","Токк","Фенг","Хенк","Холг","Шамп"],
"F": ["Багги","Вола","Волен","Евельда","Кансиф","Мев","Нига","Овак","Оунка","Сута","Шаута","Эмен","Энгонг"],
},
"languages": [Common, OrcLanguage],
},
"Halfling": {
"chance": 3.00,
"subraces": {
"Ghostwise": {"chance": 10.00},
"Lightfoot": {"chance": 60.00},
"Stout": {"chance": 30.00},
},
"names": {
"M": ["Альтон","Андер","Блазанар","Вилимак","Гаррет","Далабрак","Кейд","Коррин",
"Коркаури","Лайл","Линдал","Майло","Меррик","Омберт","Осборн","Перрин","Рид",
"Роберк","Роско","Тирраури","Уэллби","Финнан","Халандар","Элдон","Эррих"],
"F": ["Алонирра","Бри","Вани","Венинда","Верна","Делдира","Джиллиан","Калатра",
"Китри","Кора","Кэлли","Лавиния","Лидда","Мелинден","Мерла","Недда","Олпара",
"Паэла","Портия","Росинден","Серафина","Тара","Трим","Шаэна","Эндри","Юфемия"],
"S": ["Аумбль","Брамблфут","Вверхтормашкин","Высокохолм","Галькоброс","Дардаргон",
"Добробочка","Зеленофляг","Кустосбор","Лугодуг","Мерримар","Подветкин",
"Репейник","Старнхап","Хардингдейл","Чайнолист"],
},
"languages": [Common, HalflingLanguage],
},
"Hobgoblin": {
"chance": 0.50,
"names": "Goblin",
"languages": [Common, GoblinLanguage],
},
"Human": {
"chance": 54.70,
"subraces": {
"Calishite": {
"chance": 20.00,
"names": {"M":["Асеир","Бардеид","Зашеир","Кхемед","Мехмен","Судейман","Хасеид"],
"F":["Атала","Джасмаль","Зашеида","Мейлиль","Сейдиль","Сейпора","Хама","Яшеира"],
"S":["Баша","Джассан","Думеин","Кхалид","Мостана","Пашар","Рейн"]},
"languages": [Common, Alzhedo],
},
"Chondathan": {
"chance": 20.00,
"names": {"M":["Горстаг","Грим","Дарвин","Дорн","Маларк","Морн","Рэндал","Стедд","Хельм","Эвендур"],
"F":["Арвин","Джессаиль","Керри","Лурин","Мири","Рован","Тесселе","Шандри","Эсвель"],
"S":["Бакмэн","Грэйкасл","Дандрэгон","Толстаг","Эвенвуд","Эмблкроун"]},
"languages": [Common, Chondathan],
},
"Damaran": {
"chance": 15.00,
"names": {"M":["Айвор","Бор","Глэр","Григор","Иган","Козеф","Миваль","Орел","Павел","Сергор","Фодель"],
"F":["Алетра","Зора","Кара","Катернин","Мара","Натали","Ольма","Тана"],
"S":["Берск","Дотск","Куленов","Марск","Немецк","Стараг","Чернин","Шемов"]},
"languages": [Common, Damaran],
},
"Illuskan": {
"chance": 15.00,
"names": {"M":["Блас","Бран","Гет","Ландер","Лют","Малсер","Стор","Таман","Урт","Фрат","Эндер"],
"F":["Амафрея","Бетха","Вестра","Кетра","Мара","Ольга","Силифрей","Цефрея"],
"S":["Брайтвуд","Виндривер","Лакмэн","Хелдер","Хорнрейвен","Штормвинд"]},
"languages": [Common, Illuskan],
},
"Koryoan": {
"chance": 2.50,
"names": "Shou",
"languages": [Common, KoryoLanguage],
},
"Mulan": {
"chance": 10.00,
"names": {"M":["Аот","Барерис","Кетот","Мумед","Рамас","Со-Кехур","Тхазар-Де","Урхур","ЭхпутКи"],
"F":["Аризима","Золис","Мурити","Нефис","Нулара","Сефрис","Тола","Умара","Чатхи"],
"S":["Анкхалаб","Анскульд","Натандем","Серпет","Уутракт","Фезим","Хахпет"]},
"languages": [Common, Mulani],
},
"Rashemi": {
"chance": 7.50,
"names": {"M":["Боривик","Владислак","Джандар","Канитар","Мадислак","Ральмевик","Фаургар","Шаумар"],
"F":["Имзель","Иммит","Наварра","Таммит","Файварра","Хулмарра","Шеварра","Юльдра"],
"S":["Дайрнина","Илтазяра","Мурнитара","Стаянога","Улмокина","Чергоба"]},
"languages": [Common, Rashemi],
},
"Shou": {
"chance": 2.50,
"names": {"M":["Ан","Вэнь","Лонг","Лянь","Менг","Он","Фай","Цзюн","Цзянь","Чен","Чи","Шань","Шуй"],
"F":["Бай","Ксяо","Лей","Мей","Тай","Цзя","Чао","Шуй"],
"S":["Вань","Као","Кунг","Лао","Линг","Мей","Пинь","Сум","Тань","Хуан","Чиень","Шин"]},
"languages": [Common, Shou],
},
"Tethyrian": {
"chance": 7.50,
"names": "Chondathan",
"languages": [Common, Chondathan],
},
},
},
"Kenku": {
"chance": 0.50,
"names": {
"M": ["Разбиватель","Лязгун","Разрезатель","Грохотун","Крысиный Скрежет","Свистун","Мышатник","Рычун"],
"F": ["Свистун","Мышатник","Рычун"],
},
"languages": [Common, Primordial],
},
"Kobold": {"chance": 0.50, "names": [], "languages": [Common, Draconic]},
"Leonin": {
"chance": 0.10,
"names": {
"M": ["Апто","Ат","Барагон","Брыг","Горий","Грекс","Земн","Зиор","Орий","Пиксатор","Тей","Эрем"],
"F": ["Алета","Атагона","Грагонда","Демна","Доксия","Зиора","Иадма","Койла","Орамна","Сеза","Эката","Эриз"],
},
"languages": [Common, LeoninLanguage],
},
"Lizardfolk": {"chance": 0.50, "names": [], "languages": [Common, Draconic]},
"Orc": {
"chance": 2.00,
"names": {
"M": ["Абзуг","Бажок","Беск","Готог","Груток","Джахрук","Дурт","Кеск","Лортар","Мобад",
"Мугруб","Орруск","Рук","Рхог","Тараг","Тог","Угурт","Урзул","Фанг","Харл","Шамок","Шугог"],
"F": ["Багги","Бетарра","Варра","Вола","Волен","Дуваега","Имен","Йескарра","Кансиф","Креске",
"Муай","Неске","Ниига","Овак","Орваега","Оунка","Сута","Шаута","Эдарреске","Энгонг"],
"S": ["Думник","Хортор","Ламмар","Сормужик","Турнчерпал","Улькруннар","Зоргар"],
},
"languages": [Common, OrcLanguage],
},
"Satyr": {"chance": 0.10, "names": [], "languages": [Common, Sylvan]},
"Tabaxi": {"chance": 0.50, "names": [], "languages": [Common, living_languages_except_common]},
"Tiefling": {
"chance": 1.00,
"names": {
"M": ["Акменос","Амнон","Баракас","Дамакос","Йадос","Кайрон","Люцис","Мелех","Мордай",
"Мортос","Пелайос","Скамос","Терай","Экемон"],
"F": ["Акта","Анакис","Брисеис","Дамая","Каллиста","Криелла","Лерисса","Макария",
"Немея","Орианна","Риета","Фелая","Эа"],
"S": ["Амезис","Барбатос","Дракос","Инфернус","Мефисто","Фирнас","Хекатон","Харгрейв","Эбони"],
},
"languages": [Common, Infernal],
},
"Triton": {
"chance": 0.10,
"names": {
"M": ["Водос","Делнис","Джимас","Зунис","Керос","Корус","Молос","Налос"],
"F": ["Белтин","Вуолин","Влэрин","Дьютин","Отанин","Филорин","Шелрин","Эрин"],
},
"languages": [Common, Primordial],
},
"Yuan-ti Pureblood": {
"chance": 0.10,
"names": [],
"languages": [Common, Abyssal, Draconic],
},
}
# ─── Вспомогательные функции ─────────────────────────────────────────────────
def _weighted_choice(items: list[tuple[str, float]]) -> str:
"""items = [(name, chance), ...]. Возвращает выбранное name."""
total = sum(c for _, c in items)
r = random.random() * total
cumulative = 0.0
for name, chance in items:
cumulative += chance
if r <= cumulative:
return name
return items[-1][0]
def select_random_race_and_subrace() -> dict:
"""Возвращает {"race": str, "subrace": str | None}."""
race_items = [(r, d["chance"]) for r, d in RACES.items()]
race_name = _weighted_choice(race_items)
race_data = RACES[race_name]
subrace_name = None
if "subraces" in race_data and race_data["subraces"]:
sub_items = [(s, d["chance"]) for s, d in race_data["subraces"].items()]
subrace_name = _weighted_choice(sub_items)
return {"race": race_name, "subrace": subrace_name}
def get_name(race_obj: dict, gender: str) -> dict:
"""Возвращает {"name": str, "race": str, "subrace": str | None}."""
gender_key = gender.upper()
race_key = race_obj["race"]
subrace_key = race_obj.get("subrace")
def resolve_names(r_key: str, s_key: str | None):
"""Рекурсивно разрешает ссылки на имена, возвращает (nameData, source_race, source_subrace)."""
race_data = RACES.get(r_key)
if not race_data:
return None, None, None
# Определяем имена
names = None
is_race_level_ref = False
if s_key and "subraces" in race_data and s_key in race_data["subraces"]:
sub_data = race_data["subraces"][s_key]
if "names" in sub_data:
names = sub_data["names"]
else:
names = race_data.get("names")
is_race_level_ref = True
else:
if "subraces" in race_data and race_data["subraces"]:
# Выбираем случайную подрасу с именами
for candidate in random.sample(list(race_data["subraces"].keys()),
len(race_data["subraces"])):
if "names" in race_data["subraces"][candidate]:
names = race_data["subraces"][candidate]["names"]
s_key = candidate
break
if names is None:
names = race_data.get("names")
is_race_level_ref = True
else:
names = race_data.get("names")
is_race_level_ref = True
if names is None:
return None, None, None
# Строка-ссылка
if isinstance(names, str):
if is_race_level_ref:
return resolve_names(names, None)
else:
return resolve_names(r_key, names)
# Список ссылок (Half-Elf: ["Human", "Elf"])
if isinstance(names, list) and names and isinstance(names[0], str):
ref = random.choice(names)
return resolve_names(ref, None)
# Прямой словарь {"M":[], "F":[], "S":[]}
if isinstance(names, dict):
name_data: dict[str, list] = {"M": [], "F": [], "S": []}
for g in ("M", "F", "S"):
if g in names:
name_data[g] = list(names[g])
return name_data, r_key, s_key
return None, None, None
name_data, src_race, src_subrace = resolve_names(race_key, subrace_key)
if not name_data:
return {"name": "", "race": race_key, "subrace": subrace_key}
first_names = name_data.get(gender_key, [])
if not first_names:
first_names = name_data.get("F" if gender_key == "M" else "M", [])
if not first_names:
return {"name": "", "race": src_race, "subrace": src_subrace}
surname_list = name_data.get("S", [])
first_name = random.choice(first_names)
surname = random.choice(surname_list) if surname_list else ""
return {
"name": f"{first_name} {surname}".strip(),
"race": src_race,
"subrace": src_subrace,
}
def get_languages(race_obj: dict, full_name_obj: dict) -> list[dict]:
"""Возвращает список языков для расы/подрасы персонажа."""
race_key = race_obj["race"]
subrace_key = race_obj.get("subrace")
def get_lang_list(r_key: str, s_key: str | None) -> list:
r_data = RACES.get(r_key)
if not r_data:
return []
if s_key and "subraces" in r_data and s_key in r_data["subraces"]:
sub = r_data["subraces"][s_key]
if "languages" in sub:
return sub["languages"]
return r_data.get("languages", [])
lang_list = get_lang_list(race_key, subrace_key)
languages: list[dict] = []
for item in lang_list:
if isinstance(item, list):
# Массив языков на выбор — выбираем один случайный, не совпадающий с уже добавленными
available = [l for l in item if l not in languages]
if available:
languages.append(random.choice(available))
else:
languages.append(item)
# Дедупликация с сохранением порядка
seen = []
for lang in languages:
if lang not in seen:
seen.append(lang)
return seen

105
generate_npc/skills.js Normal file
View File

@ -0,0 +1,105 @@
// skills
let acrobatics = "Acrobatics";
let animalHandling = "Animal Handling";
let arcana = "Arcana";
let athletics = "Athletics";
let deception = "Deception";
let history = "History";
let insight = "Insight";
let intimidation = "Intimidation";
let investigation = "Investigation";
let medicine = "Medicine";
let nature = "Nature";
let perception = "Perception";
let performance = "Performance";
let persuasion = "Persuasion";
let religion = "Religion";
let sleightOfHand = "Sleight of Hand";
let stealth = "Stealth";
let survival = "Survival";
let all_skills = [acrobatics, animalHandling, arcana, athletics, deception,
history, insight, intimidation, investigation, medicine,
nature, perception, performance, persuasion, religion,
sleightOfHand, stealth, survival];
let bard_skills = all_skills;
let bard_skills_nr = 3;
let barbarian_skills = [athletics, perception, survival, intimidation, nature, animalHandling];
let barbarian_skills_nr = 2;
let fighter_skills = [acrobatics, athletics, perception, survival, intimidation, history, insight, animalHandling];
let fighter_skills_nr = 2;
let wizard_skills = [history, arcana, medicine, insight, investigation, religion];
let wizard_skills_nr = 2;
let druid_skills = [perception, survival, arcana, medicine, animalHandling, nature, insight, religion];
let druid_skills_nr = 2;
let cleric_skills = [history, medicine, insight, religion, persuasion];
let cleric_skills_nr = 2;
let warlock_skills = [intimidation, history, arcana, deception, nature, investigation, religion];
let warlock_skills_nr = 2;
let monk_skills = [acrobatics, athletics, history, insight, religion, stealth];
let monk_skills_nr = 2;
let paladin_skills = [athletics, intimidation, medicine, insight, religion, persuasion];
let paladin_skills_nr = 2;
let rogue_skills = [acrobatics, athletics, perception, performance, intimidation, sleightOfHand, deception, insight, investigation, stealth, persuasion];
let rogue_skills_nr = 4;
let ranger_skills = [athletics, perception, survival, nature, insight, investigation, stealth, animalHandling];
let ranger_skills_nr = 3;
let sorcerer_skills = [intimidation, arcana, deception, insight, religion, persuasion];
let sorcerer_skills_nr = 2;
let characterClassesSkills = {
bard: [bard_skills, bard_skills_nr],
barbarian: [barbarian_skills, barbarian_skills_nr],
fighter: [fighter_skills, fighter_skills_nr],
wizard: [wizard_skills, wizard_skills_nr],
druid: [druid_skills, druid_skills_nr],
cleric: [cleric_skills, cleric_skills_nr],
warlock: [warlock_skills, warlock_skills_nr],
monk: [monk_skills, monk_skills_nr],
paladin: [paladin_skills, paladin_skills_nr],
rogue: [rogue_skills, rogue_skills_nr],
ranger: [ranger_skills, ranger_skills_nr],
sorcerer: [sorcerer_skills, sorcerer_skills_nr]
};
// Функция для получения случайных элементов из массива
function getRandomSkills(skillsList, skillsNr) {
let selectedSkills = [];
let availableSkills = [...skillsList]; // Копируем список навыков, чтобы не изменять исходный
for (let i = 0; i < skillsNr; i++) {
let randomIndex = Math.floor(Math.random() * availableSkills.length);
selectedSkills.push(availableSkills[randomIndex]);
availableSkills.splice(randomIndex, 1); // Убираем выбранный навык, чтобы не выбрать его снова
}
return selectedSkills;
}
dv.skills = {
acrobatics,
animalHandling,
arcana,
athletics,
deception,
history,
insight,
intimidation,
investigation,
medicine,
nature,
perception,
performance,
persuasion,
religion,
sleightOfHand,
stealth,
survival,
characterClassesSkills,
getRandomSkills
}

55
generate_npc/skills.py Normal file
View File

@ -0,0 +1,55 @@
# skills.py
import random
acrobatics = "Acrobatics"
animal_handling= "Animal Handling"
arcana = "Arcana"
athletics = "Athletics"
deception = "Deception"
history = "History"
insight = "Insight"
intimidation = "Intimidation"
investigation = "Investigation"
medicine = "Medicine"
nature = "Nature"
perception = "Perception"
performance = "Performance"
persuasion = "Persuasion"
religion = "Religion"
sleight_of_hand= "Sleight of Hand"
stealth = "Stealth"
survival = "Survival"
all_skills = [
acrobatics, animal_handling, arcana, athletics, deception,
history, insight, intimidation, investigation, medicine,
nature, perception, performance, persuasion, religion,
sleight_of_hand, stealth, survival,
]
CHARACTER_CLASS_SKILLS: dict[str, tuple[list[str], int]] = {
"bard": (all_skills, 3),
"barbarian": ([athletics, perception, survival, intimidation, nature, animal_handling], 2),
"fighter": ([acrobatics, athletics, perception, survival, intimidation, history, insight, animal_handling], 2),
"wizard": ([history, arcana, medicine, insight, investigation, religion], 2),
"druid": ([perception, survival, arcana, medicine, animal_handling, nature, insight, religion], 2),
"cleric": ([history, medicine, insight, religion, persuasion], 2),
"warlock": ([intimidation, history, arcana, deception, nature, investigation, religion], 2),
"monk": ([acrobatics, athletics, history, insight, religion, stealth], 2),
"paladin": ([athletics, intimidation, medicine, insight, religion, persuasion], 2),
"rogue": ([acrobatics, athletics, perception, performance, intimidation, sleight_of_hand,
deception, insight, investigation, stealth, persuasion], 4),
"ranger": ([athletics, perception, survival, nature, insight, investigation, stealth, animal_handling], 3),
"sorcerer": ([intimidation, arcana, deception, insight, religion, persuasion], 2),
"commoner": ([animal_handling, athletics, persuasion, insight, survival], 1),
}
def get_random_skills(skills_list: list[str], count: int) -> list[str]:
"""Возвращает `count` случайных навыков без повторений."""
available = list(skills_list)
selected = []
for _ in range(min(count, len(available))):
idx = random.randrange(len(available))
selected.append(available.pop(idx))
return selected

View File

@ -0,0 +1,583 @@
"""
generate_trap.py генератор случайных ловушек по параметрам окружения, уровня и типа.
Интегрирует:
- Библиотеку ловушек из DMG, XGE, VRGR и пользовательских примеров.
- Автоматический расчёт урона, Сл спасброска и бонуса атаки по таблицам угроз (moderate/dangerous/deadly).
- Поддержку простых (simple) и сложных (complex) ловушек.
- Особые условия избегания (пароль, предмет, тип существа).
Экспортирует функцию generate_trap(debug_description, requests).
requests список объектов вида:
{
"environment": "dungeon", # локация
"level": 5, # уровень группы
"trap_type": "mechanical", # mechanical/magical/haunted/living
"complexity": "simple", # simple/complex
"severity": "dangerous", # moderate/dangerous/deadly
"trigger_type": "pressure_plate",
"trigger_location": "floor",
"effect_type": "damage",
"damage_type": "fire",
"area_shape": "cone",
"duration": "instant",
"detection_skill": "perception",
"disarm_skill": "thieves_tools",
"requires_tools": True,
"reset": "once",
"avoidable_by": ["password"]
}
Возвращает строку в формате Markdown.
"""
import sys
import os
from pathlib import Path
import random
from typing import Dict, List, Optional, Any
from typing_extensions import TypedDict
# ──────────────────────────────────────────────────────────────────────────────
# TypedDict для параметров запроса
# ──────────────────────────────────────────────────────────────────────────────
class TrapRequest(TypedDict, total=False):
"""
Параметры одного запроса на генерацию ловушки.
Поля:
environment: Тип локации.
Допустимы: dungeon, forest, urban, tomb, cave,
arctic, swamp, ruins, castle, sewer, laboratory.
По умолчанию: dungeon.
level: Средний уровень группы (120).
По умолчанию: 1.
trap_type: Тип ловушки по природе.
Допустимы: mechanical (механическая), magical (магическая),
haunted (призрачная), living (живая).
По умолчанию: случайно.
complexity: Сложность поведения.
Допустимы: simple (простая), complex (сложная).
По умолчанию: simple (с весами).
severity: Серьёзность угрозы.
Допустимы: moderate (средняя), dangerous (опасная), deadly (смертельная).
По умолчанию: moderate (с весами).
trigger_type: Как активируется ловушка.
Допустимы: pressure_plate, trip_wire, door_open, lock_pick,
touch, time, magical_ward, manual, force, enter_area.
По умолчанию: случайно.
trigger_location: Где находится триггер.
Допустимы: floor, wall, ceiling, door, chest, object, corridor.
По умолчанию: floor.
effect_type: Основной эффект ловушки.
Допустимы: damage, condition, restrain, imprison, teleport,
summon, alarm, delay, environment, curse.
По умолчанию: damage (с весами).
damage_type: Тип урона (если effect_type=damage).
Допустимы: poison, fire, cold, acid, lightning,
bludgeoning, piercing, slashing, psychic, necrotic,
radiant, thunder.
По умолчанию: выбирается случайно.
area_shape: Форма области поражения.
Допустимы: single_target, cone, cube, line, sphere, room, pit.
По умолчанию: single_target.
duration: Продолжительность эффекта.
Допустимы: instant, persistent, repeating, until_escape.
По умолчанию: instant.
detection_skill: Навык для обнаружения ловушки.
Допустимы: perception, investigation, arcana, none.
По умолчанию: perception.
disarm_skill: Навык/способ обезвреживания.
Допустимы: thieves_tools, athletics, arcana, religion, simple_action, none.
По умолчанию: thieves_tools.
requires_tools: Требуются ли воровские инструменты для обезвреживания.
По умолчанию: True.
reset: Перезарядка ловушки.
Допустимы: once (одноразовая), automatic (автоматическая), manual (ручная).
По умолчанию: once.
avoidable_by: Особые условия избегания.
Допустимы: password, specific_item, specific_creature_type.
По умолчанию: [].
"""
environment: str
level: int
trap_type: str
complexity: str
severity: str
trigger_type: str
trigger_location: str
effect_type: str
damage_type: str
area_shape: str
duration: str
detection_skill: str
disarm_skill: str
requires_tools: bool
reset: str
avoidable_by: List[str]
# ──────────────────────────────────────────────────────────────────────────────
# Библиотека готовых ловушек (упрощённая структура для примера)
# В реальном проекте можно расширить до полноценной базы.
# ──────────────────────────────────────────────────────────────────────────────
# Шаблоны описаний и механик для генерации на основе параметров
TRAP_TEMPLATES = {
"mechanical": {
"damage": "Механическая ловушка наносит {damage} урона {damage_type} в области {area_shape}.",
"condition": "Механическая ловушка накладывает состояние {condition}.",
"restrain": "Механическая ловушка захватывает жертву, требуя проверки Силы для освобождения.",
"imprison": "Механическая ловушка запирает жертву в {area_shape}.",
"teleport": "Механическая ловушка телепортирует жертву в другую зону.",
"summon": "Механическая ловушка призывает {summon}.",
"alarm": "Механическая ловушка издаёт громкий звук, привлекая внимание.",
"delay": "Механическая ловушка создаёт препятствие, блокируя проход.",
"environment": "Механическая ловушка изменяет физические свойства среды.",
"curse": "Механическая ловушка накладывает проклятие."
},
"magical": {
"damage": "Магическая ловушка поражает {area_shape} заклинанием, наносящим {damage} урона {damage_type}.",
"condition": "Магическая ловушка накладывает магическое состояние {condition}.",
"restrain": "Магическая ловушка удерживает цель магическими цепями.",
"imprison": "Магическая ловушка заточает жертву в магическую темницу.",
"teleport": "Магическая ловушка телепортирует жертву в заранее определённое место.",
"summon": "Магическая ловушка призывает {summon} с помощью заклинания.",
"alarm": "Магическая ловушка посылает магический сигнал тревоги.",
"delay": "Магическая ловушка создаёт барьер из чистой магии.",
"environment": "Магическая ловушка меняет законы физики в области.",
"curse": "Магическая ловушка накладывает магическое проклятие."
},
"haunted": {
"damage": "Призрачная ловушка наносит {damage} психического или некротического урона.",
"condition": "Призрачная ловушка вызывает страх, очарование или паралич.",
"restrain": "Призрачная ловушка парализует жертву от ужаса.",
"imprison": "Призрачная ловушка запирает душу в предмете.",
"teleport": "Призрачная ловушка отправляет жертву в иное измерение.",
"summon": "Призрачная ловушка материализует призрачного музыканта или воина.",
"alarm": "Призрачная ловушка издаёт жуткие звуки, предупреждающие обитателей.",
"delay": "Призрачная ловушка заставляет жертву танцевать, теряя время.",
"environment": "Призрачная ловушка создаёт иллюзию прошлого, изменяя восприятие.",
"curse": "Призрачная ловушка накладывает проклятие, снимаемое только Remove Curse."
},
"living": {
"damage": "Живая ловушка атакует, нанося {damage} урона {damage_type}.",
"condition": "Живая ловушка отравляет, парализует или опутывает жертву.",
"restrain": "Живая ловушка захватывает жертву щупальцами или клешнями.",
"imprison": "Живая ловушка заглатывает жертву.",
"teleport": "Живая ловушка переносит жертву в своё логово.",
"summon": "Живая ловушка призывает других существ.",
"alarm": "Живая ловушка издаёт крик, зовущий сородичей.",
"delay": "Живая ловушка опутывает жертву, замедляя её.",
"environment": "Живая ловушка выделяет слизь, делающую пол скользким.",
"curse": "Живая ловушка накладывает болезнь или проклятие через укус."
}
}
# Соответствие типов урона спасброскам
DAMAGE_SAVE_MAP = {
"poison": "Телосложение",
"fire": "Ловкость",
"cold": "Телосложение",
"acid": "Ловкость",
"lightning": "Ловкость",
"bludgeoning": "Ловкость",
"piercing": "Ловкость",
"slashing": "Ловкость",
"psychic": "Интеллект",
"necrotic": "Мудрость",
"radiant": "Мудрость",
"thunder": "Телосложение"
}
# ──────────────────────────────────────────────────────────────────────────────
# Вспомогательные функции — случайный выбор, веса, расчёт чисел
# ──────────────────────────────────────────────────────────────────────────────
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 _random_choice_or_default(value: Optional[str], options: List[str], default: str) -> str:
"""Если value задано и входит в options, возвращает его. Иначе случайный выбор из options."""
if value and value in options:
return value
return random.choice(options) if options else default
def _calculate_damage_and_dc(level: int, severity: str, complexity: str) -> Dict[str, Any]:
"""Рассчитывает урон, Сл спасброска, бонус атаки по таблицам DMG/XGE."""
# Таблицы из официальных правил (средняя/опасная/смертельная)
# Урон в d10 (для одиночной цели). Для площади можно уменьшать количество кубов.
damage_table = {
"moderate": {
1: (1, 10), # 1d10
5: (2, 10), # 2d10
11: (4, 10), # 4d10
17: (10, 10) # 10d10
},
"dangerous": {
1: (2, 10), # 2d10
5: (4, 10), # 4d10
11: (10, 10), # 10d10
17: (18, 10) # 18d10
},
"deadly": {
1: (4, 10), # 4d10
5: (10, 10), # 10d10
11: (18, 10), # 18d10
17: (24, 10) # 24d10
}
}
# Определяем порог уровня
if level <= 4:
tier = 1
elif level <= 10:
tier = 5
elif level <= 16:
tier = 11
else:
tier = 17
num_dice, die_size = damage_table[severity][tier]
# Для сложных ловушек урон может быть немного ниже (повторяемость)
if complexity == "complex":
num_dice = max(1, num_dice - 1)
damage_dice = f"{num_dice}d{die_size}"
# Средний урон для текста
avg_damage = num_dice * (die_size + 1) // 2
# Сл спасброска и бонус атаки
dc_save_table = {
"moderate": 10,
"dangerous": 15,
"deadly": 20
}
base_dc = dc_save_table[severity]
# Для сложных ловушек Сл может быть чуть ниже (чтобы не убить за раз)
if complexity == "complex":
base_dc = max(10, base_dc - 2)
attack_bonus_table = {
"moderate": 5,
"dangerous": 8,
"deadly": 12
}
base_attack = attack_bonus_table[severity]
if complexity == "complex":
base_attack = max(3, base_attack - 2)
return {
"damage_dice": damage_dice,
"avg_damage": avg_damage,
"save_dc": base_dc,
"attack_bonus": base_attack
}
def _pick_condition(effect_type: str, damage_type: str = None) -> str:
"""Выбирает состояние для effect_type='condition' на основе damage_type или случайно."""
if effect_type != "condition":
return ""
condition_map = {
"poison": "отравлен",
"fire": "горит",
"cold": "замедлен",
"lightning": "оглушён",
"psychic": "ошеломлён",
"necrotic": "истощён",
"radiant": "ослеплён",
"thunder": "оглушён"
}
if damage_type and damage_type in condition_map:
return condition_map[damage_type]
# Случайные состояния
conditions = ["отравлен", "ослеплён", "оглушён", "ошеломлён", "напуган", "парализован"]
return random.choice(conditions)
def _generate_description(req: TrapRequest, calc: Dict[str, Any]) -> str:
"""Генерирует текст описания ловушки на основе параметров и рассчитанных чисел."""
trap_type = req.get("trap_type", "mechanical")
effect_type = req.get("effect_type", "damage")
damage_type = req.get("damage_type", "bludgeoning")
area_shape = req.get("area_shape", "single_target")
trigger_type = req.get("trigger_type", "pressure_plate")
trigger_location = req.get("trigger_location", "floor")
detection_skill = req.get("detection_skill", "perception")
disarm_skill = req.get("disarm_skill", "thieves_tools")
requires_tools = req.get("requires_tools", True)
reset = req.get("reset", "once")
avoidable = req.get("avoidable_by", [])
complexity = req.get("complexity", "simple")
duration = req.get("duration", "instant")
# Базовый шаблон из библиотеки
template = TRAP_TEMPLATES.get(trap_type, TRAP_TEMPLATES["mechanical"]).get(effect_type, "Ловушка срабатывает.")
# Подставляем значения
damage_str = f"{calc['damage_dice']} ({calc['avg_damage']})"
desc = template.format(
damage=damage_str,
damage_type=damage_type,
area_shape=area_shape,
condition=_pick_condition(effect_type, damage_type),
summon="монстра" # упрощённо
)
# Добавляем триггер
trigger_text = {
"pressure_plate": f"на нажимную плиту на {trigger_location} оказывается вес более 20 фунтов",
"trip_wire": f"растяжка на {trigger_location} задевается существом",
"door_open": f"открывается дверь/сундук на {trigger_location}",
"lock_pick": f"кто-то пытается взломать замок на {trigger_location} неправильным ключом",
"touch": f"кто-то прикасается к предмету на {trigger_location}",
"time": f"проходит определённое время",
"magical_ward": f"магическая стража на {trigger_location} обнаруживает нарушителя",
"manual": f"кто-то дёргает за рычаг на {trigger_location}",
"force": f"к {trigger_location} прилагается физическая сила (удар или выбивание)",
"enter_area": f"существо входит в область на {trigger_location}"
}.get(trigger_type, f"происходит {trigger_type}")
full_desc = f"**Триггер:** Ловушка срабатывает, когда {trigger_text}.\n**Эффект:** {desc}"
# Добавляем информацию о спасброске и уроне, если effect_type damage
if effect_type == "damage":
save_ability = DAMAGE_SAVE_MAP.get(damage_type, "Ловкость")
full_desc += f" Каждое существо в области должно преуспеть в спасброске {save_ability} (Сл {calc['save_dc']}), иначе получит {damage_str} урона {damage_type} (или половину при успехе)."
if damage_type in ["fire", "acid"]:
full_desc += " Легковоспламеняющиеся предметы загораются."
elif effect_type == "condition":
cond = _pick_condition(effect_type, damage_type)
full_desc += f" Цель должна преуспеть в спасброске Мудрости (Сл {calc['save_dc']}), иначе получит состояние '{cond}' на 1 минуту."
elif effect_type in ["restrain", "imprison"]:
full_desc += f" Цель должна преуспеть в спасброске Ловкости (Сл {calc['save_dc']}), иначе становится опутанной/запертой. Для освобождения требуется проверка Силы (Сл 15)."
# Обнаружение и обезвреживание
detect_desc = ""
if detection_skill == "perception":
detect_desc = f"Успешная проверка Мудрости (Внимательность) Сл {calc['save_dc']} позволяет заметить признаки ловушки."
elif detection_skill == "investigation":
detect_desc = f"Успешная проверка Интеллекта (Расследование) Сл {calc['save_dc']} раскрывает механизм ловушки."
elif detection_skill == "arcana":
detect_desc = f"Успешная проверка Интеллекта (Магия) Сл {calc['save_dc']} обнаруживает магическую ауру."
else:
detect_desc = "Ловушка почти не оставляет следов; обнаружить её можно только случайно или по подсказкам."
disarm_desc = ""
if disarm_skill == "thieves_tools":
tool_req = " (требуются воровские инструменты)" if requires_tools else ""
disarm_desc = f"Успешная проверка Ловкости с использованием воровских инструментов{'' if requires_tools else ' (можно без инструментов со помехой)'} Сл {calc['save_dc']} обезвреживает ловушку."
elif disarm_skill == "athletics":
disarm_desc = f"Успешная проверка Силы (Атлетика) Сл {calc['save_dc']} позволяет сломать или заблокировать механизм."
elif disarm_skill == "arcana":
disarm_desc = f"Успешная проверка Интеллекта (Магия) Сл {calc['save_dc']} или заклинание `dispel magic` (Сл {calc['save_dc']}) отключает магическую ловушку."
elif disarm_skill == "religion":
disarm_desc = f"Успешная проверка Интеллекта (Религия) Сл {calc['save_dc']} позволяет стереть священную руну."
elif disarm_skill == "simple_action":
disarm_desc = "Достаточно простого действия: перешагнуть, забить клин или перерезать верёвку."
else:
disarm_desc = "Ловушку невозможно обезвредить; её можно только избежать."
reset_desc = {
"once": "Ловушка одноразовая; после срабатывания не представляет угрозы.",
"automatic": f"Через {random.randint(1, 10)} минут ловушка перезаряжается автоматически.",
"manual": "Ловушку можно взвести заново, потратив 10 минут и сделав проверку."
}.get(reset, "")
avoid_desc = ""
if avoidable:
avoid_list = []
if "password" in avoidable:
avoid_list.append("знать пароль")
if "specific_item" in avoidable:
avoid_list.append("иметь особый предмет (ключ, амулет, святой символ)")
if "specific_creature_type" in avoidable:
avoid_list.append("принадлежать к определённому типу существ (например, нежить)")
avoid_desc = f"\n**Избегание:** Ловушку можно полностью избежать, если {', '.join(avoid_list)}."
complexity_desc = ""
if complexity == "complex":
init_bonus = random.randint(5, 12)
complexity_desc = f"\n**Сложная ловушка:** Действует на инициативе {random.choice([10, 20])} (бонус {init_bonus:+d}). В свой ход повторяет эффект. Требуется 3 успешные проверки для отключения."
full_desc += f"\n\n**Обнаружение:** {detect_desc}\n**Обезвреживание:** {disarm_desc}\n**Перезарядка:** {reset_desc}{avoid_desc}{complexity_desc}"
return full_desc
# ──────────────────────────────────────────────────────────────────────────────
# Публичная функция
# ──────────────────────────────────────────────────────────────────────────────
def generate_trap(debug_description: str, requests: List[TrapRequest]) -> str:
"""
Основной метод, который вызывает тул с фронта.
Принимает список запросов на генерацию ловушек. Для каждого запроса:
1. Определяет все параметры (с подстановкой значений по умолчанию).
2. Рассчитывает урон/Сл/бонус атаки на основе уровня и серьёзности.
3. Генерирует описание, триггер, эффект, способы обнаружения и обезвреживания.
4. Формирует Markdown-секцию.
Возвращает Markdown-строку со всеми результатами.
"""
if not requests:
requests = [{}]
results = []
for i, req in enumerate(requests, 1):
# Подстановка значений по умолчанию
env = req.get("environment", "dungeon").lower()
level = max(1, min(20, int(req.get("level", 1))))
trap_type = req.get("trap_type")
complexity = req.get("complexity", "simple")
severity = req.get("severity", "moderate")
# Случайный выбор, если не указано или значение недопустимо
valid_trap_types = ["mechanical", "magical", "haunted", "living"]
trap_type = _random_choice_or_default(trap_type, valid_trap_types, "mechanical")
valid_complexities = ["simple", "complex"]
complexity = _random_choice_or_default(complexity, valid_complexities, "simple")
valid_severities = ["moderate", "dangerous", "deadly"]
severity = _random_choice_or_default(severity, valid_severities, "moderate")
valid_trigger_types = ["pressure_plate", "trip_wire", "door_open", "lock_pick", "touch", "time", "magical_ward", "manual", "force", "enter_area"]
trigger_type = _random_choice_or_default(req.get("trigger_type"), valid_trigger_types, "pressure_plate")
valid_trigger_locations = ["floor", "wall", "ceiling", "door", "chest", "object", "corridor"]
trigger_location = _random_choice_or_default(req.get("trigger_location"), valid_trigger_locations, "floor")
valid_effect_types = ["damage", "condition", "restrain", "imprison", "teleport", "summon", "alarm", "delay", "environment", "curse"]
effect_type = _random_choice_or_default(req.get("effect_type"), valid_effect_types, "damage")
valid_damage_types = ["poison", "fire", "cold", "acid", "lightning", "bludgeoning", "piercing", "slashing", "psychic", "necrotic", "radiant", "thunder"]
damage_type = req.get("damage_type")
if effect_type == "damage" and (not damage_type or damage_type not in valid_damage_types):
damage_type = _random_choice_or_default(None, valid_damage_types, "bludgeoning")
elif effect_type != "damage":
damage_type = None
valid_area_shapes = ["single_target", "cone", "cube", "line", "sphere", "room", "pit"]
area_shape = _random_choice_or_default(req.get("area_shape"), valid_area_shapes, "single_target")
valid_durations = ["instant", "persistent", "repeating", "until_escape"]
duration = _random_choice_or_default(req.get("duration"), valid_durations, "instant")
valid_detection = ["perception", "investigation", "arcana", "none"]
detection_skill = _random_choice_or_default(req.get("detection_skill"), valid_detection, "perception")
valid_disarm = ["thieves_tools", "athletics", "arcana", "religion", "simple_action", "none"]
disarm_skill = _random_choice_or_default(req.get("disarm_skill"), valid_disarm, "thieves_tools")
requires_tools = req.get("requires_tools", True)
if not isinstance(requires_tools, bool):
requires_tools = True
valid_reset = ["once", "automatic", "manual"]
reset = _random_choice_or_default(req.get("reset"), valid_reset, "once")
avoidable_by = req.get("avoidable_by", [])
if not isinstance(avoidable_by, list):
avoidable_by = []
# фильтруем только допустимые значения
valid_avoid = ["password", "specific_item", "specific_creature_type"]
avoidable_by = [a for a in avoidable_by if a in valid_avoid]
# Расчёт чисел
calc = _calculate_damage_and_dc(level, severity, complexity)
# Генерация описания
desc = _generate_description({
"trap_type": trap_type,
"effect_type": effect_type,
"damage_type": damage_type,
"area_shape": area_shape,
"trigger_type": trigger_type,
"trigger_location": trigger_location,
"detection_skill": detection_skill,
"disarm_skill": disarm_skill,
"requires_tools": requires_tools,
"reset": reset,
"avoidable_by": avoidable_by,
"complexity": complexity,
"duration": duration
}, calc)
# Формирование секции Markdown
section = f"""### 🪤 Ловушка №{i}
- **Локация:** {env.capitalize()}
- **Уровень группы:** {level}
- **Тип ловушки:** {trap_type.capitalize()} | **Сложность:** {complexity.capitalize()} | **Угроза:** {severity.capitalize()}
- **Триггер:** {trigger_type.replace('_', ' ').capitalize()} ({trigger_location})
- **Эффект:** {effect_type.capitalize()} | **Область:** {area_shape.replace('_', ' ').capitalize()}
- **Длительность:** {duration.capitalize()}
**Описание:**
{desc}
"""
results.append(section)
return "\n\n---\n\n".join(results)
# ──────────────────────────────────────────────────────────────────────────────
# Быстрый тест
# ──────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
test_requests = [
{
"environment": "dungeon",
"level": 5,
"trap_type": "mechanical",
"severity": "dangerous",
"effect_type": "damage",
"damage_type": "fire",
"area_shape": "cone",
"trigger_type": "pressure_plate",
"detection_skill": "perception",
"disarm_skill": "thieves_tools",
"requires_tools": True,
"avoidable_by": ["password"]
},
{
"environment": "tomb",
"level": 12,
"trap_type": "haunted",
"complexity": "complex",
"severity": "deadly",
"effect_type": "condition",
"area_shape": "room",
"trigger_type": "enter_area",
"avoidable_by": ["specific_creature_type"]
},
{
"environment": "forest",
"level": 3,
# минимум полей — всё остальное случайно
}
]
print(generate_trap("Тест генератора ловушек", test_requests))

View File

@ -0,0 +1,622 @@
import random
from typing import Dict, List, Optional
from typing_extensions import TypedDict
# ----------------------------------------------------------------------
# TypedDict для параметров запроса
# ----------------------------------------------------------------------
class TrapRequest(TypedDict, total=False):
"""
Параметры одного запроса на генерацию ловушки.
Поля:
environment: Тип локации.
Допустимы: dungeon, forest, urban, tomb, cave,
arctic, swamp, ruins, castle, sewer, laboratory.
По умолчанию: dungeon.
level: Средний уровень группы (120).
По умолчанию: 1.
trap_type: Тип ловушки по природе.
Допустимы: mechanical (механическая), magical (магическая),
haunted (призрачная), living (живая).
По умолчанию: случайно.
complexity: Сложность поведения.
Допустимы: simple (простая), complex (сложная).
По умолчанию: simple.
severity: Серьёзность угрозы.
Допустимы: easy (лёгкая), moderate (средняя),
dangerous (опасная), deadly (смертельная).
По умолчанию: moderate.
trigger_type: Как активируется ловушка.
Допустимы: pressure_plate, trip_wire, door_open, lock_pick,
touch, time, magical_ward, manual, force, enter_area.
По умолчанию: случайно.
trigger_location: Где находится триггер.
Допустимы: floor, wall, ceiling, door, chest, object, corridor.
По умолчанию: floor.
trigger_range_ft: Расстояние, на котором триггер срабатывает.
0 = касание, положительное число = футы до цели.
По умолчанию: 0.
effect_type: Основной эффект ловушки.
Допустимы: damage, condition, restrain, imprison, teleport,
summon, alarm, delay, environment, curse, flooding, illusion.
По умолчанию: damage (случайно из damage/condition/restrain).
damage_type: Тип урона (если effect_type=damage).
Допустимы: poison, fire, cold, acid, lightning,
bludgeoning, piercing, slashing, psychic, necrotic,
radiant, thunder.
По умолчанию: выбирается случайно.
area_shape: Форма области поражения.
Допустимы: single_target, cone, cube, line, sphere, room, pit, net.
По умолчанию: single_target (случайно cone/cube).
duration: Продолжительность эффекта.
Допустимы: instant, persistent, repeating, until_escape.
По умолчанию: instant.
detection_skill: Навык для обнаружения ловушки.
Допустимы: perception, investigation, arcana, none.
По умолчанию: perception.
disarm_skill: Навык/способ обезвреживания.
Допустимы: thieves_tools, athletics, arcana, religion, simple_action, none.
По умолчанию: thieves_tools.
reset: Перезарядка ловушки.
Допустимы: once (одноразовая), manual (ручная), automatic (автоматическая).
По умолчанию: once.
avoidable_by: Особые условия избегания.
Допустимы: password, specific_item, specific_creature_type.
По умолчанию: [].
trap_race: Раса/культура создателей ловушки (влияет на визуальные/звуковые подсказки).
Допустимы: dwarven, elven, humanoid, goblinoid, giant, draconic,
elemental, undead, fey, lizardfolk, drow, aberration, thief_guild.
Если не указана, выбирается случайно на основе environment.
summon_type: Тип существа, которое призывается (если effect_type=summon).
Допустимы: aberration, giant, humanoid, dragon, beast, fiend,
construct, monstrosity, celestial, undead, plant, ooze, fey, elemental.
Если не указан, выбирается случайно на основе environment.
disarm_fail_threshold: При попытке обезвредить, если результат проверки меньше Сл на это значение или более,
ловушка срабатывает. По умолчанию 0. Для deadly рекомендуется 5.
flooding_medium: Тип жидкости при затоплении (если effect_type=flooding).
Допустимы: water, acid, lava. По умолчанию: water.
illusion_type: Тип иллюзии (если effect_type=illusion).
Допустимы: visual, auditory, tactile. По умолчанию: visual.
illusion_save: Спасбросок против иллюзии.
Допустимы: wisdom, intelligence. По умолчанию: wisdom.
"""
environment: str
level: int
trap_type: str
complexity: str
severity: str
trigger_type: str
trigger_location: str
trigger_range_ft: int # 0 = касание, иначе расстояние в футах
effect_type: str
damage_type: str
area_shape: str
duration: str
detection_skill: str
disarm_skill: str
reset: str
avoidable_by: List[str]
trap_race: str
summon_type: str
disarm_fail_threshold: int
flooding_medium: str
illusion_type: str
illusion_save: str
DAMAGE_SAVE_MAP = {
"poison": "Телосложение",
"fire": "Ловкость",
"cold": "Телосложение",
"acid": "Ловкость",
"lightning": "Ловкость",
"bludgeoning": "Ловкость",
"piercing": "Ловкость",
"slashing": "Ловкость",
"psychic": "Интеллект",
"necrotic": "Мудрость",
"radiant": "Мудрость",
"thunder": "Телосложение"
}
def _get_save_ability(damage_type: str) -> str:
return DAMAGE_SAVE_MAP.get(damage_type, "Ловкость")
AREA_SIZES = {
"cone": 15, # 15-футовый конус
"cube": 10, # 10-футовый куб
"line": 30, # 30-футовая линия
"sphere": 20, # 20-футовый радиус
"room": 30, # 30x30 комната (или всё помещение)
"pit": 10, # 10 футов глубины
"net": 10, # 10x10 футов сеть
"single_target": 0 # нет размера
}
# ----------------------------------------------------------------------
# Словари соответствий (environment -> возможные расы ловушек)
# ----------------------------------------------------------------------
TRAP_RACES_BY_ENVIRONMENT = {
"arctic": ["dwarven", "giant", "draconic"],
"coastal": ["elven", "dwarven", "humanoid"],
"desert": ["dwarven", "draconic", "elemental"],
"forest": ["elven", "goblinoid", "fey"],
"grassland": ["humanoid", "goblinoid"],
"hill": ["dwarven", "giant"],
"mountain": ["dwarven", "draconic", "giant"],
"swamp": ["undead", "lizardfolk", "goblinoid"],
"underdark": ["drow", "dwarven", "aberration"],
"underwater": ["elven", "dwarven", "draconic"],
"urban": ["humanoid", "dwarven", "thief_guild"],
"plane_of_air": ["elemental", "draconic", "humanoid"],
"plane_of_earth": ["dwarven", "elemental", "giant"],
"plane_of_fire": ["draconic", "elemental", "humanoid"],
"plane_of_water": ["elven", "elemental", "lizardfolk"],
}
# Визуальные и звуковые подсказки для каждой расы
TRAP_RACE_CLUES = {
"dwarven": {
"visual": ["gears", "rune_carvings", "stone_brick_pattern", "soot", "oil_stains"],
"audible": ["click", "clank", "grinding_stone"]
},
"elven": {
"visual": ["delicate_lines", "leaves", "moon_motif", "faint_glow"],
"audible": ["soft_chime", "whisper"]
},
"humanoid": {
"visual": ["scorch_marks", "loose_flagstone", "rope_fibers"],
"audible": ["squeak", "creak"]
},
"goblinoid": {
"visual": ["wooden_spikes", "bones", "crude_writing"],
"audible": ["jingle_bell", "twang"]
},
"giant": {
"visual": ["massive_dents", "broken_columns", "scratch_marks_high"],
"audible": ["heavy_thud", "groaning_metal"]
},
"draconic": {
"visual": ["scaly_pattern", "claw_marks", "bas-relief_dragon"],
"audible": ["hiss", "roar_echo"]
},
"elemental": {
"visual": ["glowing_veins", "crackling_energy", "charred_floor"],
"audible": ["hum", "crackle", "whoosh"]
},
"undead": {
"visual": ["cobwebs", "dust", "hidden_bones", "decayed_fabric"],
"audible": ["moan", "skittering"]
},
"fey": {
"visual": ["glowing_mushrooms", "twisted_vines", "sparkling_dust"],
"audible": ["giggle", "chime"]
},
"lizardfolk": {
"visual": ["scales", "eggshells", "claw_scratches"],
"audible": ["hiss", "scraping"]
},
"drow": {
"visual": ["spider_motif", "web_strands", "poison_droplets"],
"audible": ["skittering", "faint_scream"]
},
"aberration": {
"visual": ["organic_growth", "wrong_angles", "eyeball_patterns"],
"audible": ["whisper_gibberish", "squelch"]
},
"thief_guild": {
"visual": ["tool_marks", "hidden_catch", "false_bottom"],
"audible": ["spring_coil", "tiny_click"]
}
}
# environment -> возможные типы призываемых существ
SUMMON_TYPE_BY_ENVIRONMENT = {
"arctic": ["giant", "beast", "elemental"],
"coastal": ["beast", "elemental", "dragon"],
"desert": ["elemental", "undead", "beast"],
"forest": ["beast", "fey", "plant"],
"grassland": ["beast", "humanoid", "giant"],
"hill": ["giant", "beast", "humanoid"],
"mountain": ["giant", "dragon", "elemental"],
"swamp": ["undead", "plant", "beast"],
"underdark": ["aberration", "humanoid", "monstrosity"],
"underwater": ["beast", "elemental", "dragon"],
"urban": ["humanoid", "construct", "undead"],
"plane_of_air": ["elemental", "dragon", "beast", "giant"],
"plane_of_earth": ["elemental", "humanoid", "monstrosity", "construct"],
"plane_of_fire": ["elemental", "fiend", "dragon"],
"plane_of_water": ["elemental", "dragon", "beast", "humanoid"],
}
# ----------------------------------------------------------------------
# Таблицы CR → характеристики (из таблицы монстров)
# ----------------------------------------------------------------------
MONSTER_STATS = {
0.125: {"pb": 2, "dc": 13, "attack": 3, "hp": 21, "dmg_low": 2, "dmg_high": 3},
0.25: {"pb": 2, "dc": 13, "attack": 3, "hp": 43, "dmg_low": 4, "dmg_high": 5},
0.5: {"pb": 2, "dc": 13, "attack": 3, "hp": 60, "dmg_low": 6, "dmg_high": 8},
1: {"pb": 2, "dc": 13, "attack": 3, "hp": 78, "dmg_low": 9, "dmg_high": 14},
2: {"pb": 2, "dc": 13, "attack": 3, "hp": 93, "dmg_low": 15, "dmg_high": 20},
3: {"pb": 2, "dc": 13, "attack": 4, "hp": 108, "dmg_low": 21, "dmg_high": 26},
4: {"pb": 2, "dc": 14, "attack": 5, "hp": 123, "dmg_low": 27, "dmg_high": 32},
5: {"pb": 3, "dc": 15, "attack": 6, "hp": 138, "dmg_low": 33, "dmg_high": 38},
6: {"pb": 3, "dc": 15, "attack": 6, "hp": 153, "dmg_low": 39, "dmg_high": 44},
7: {"pb": 3, "dc": 15, "attack": 6, "hp": 168, "dmg_low": 45, "dmg_high": 50},
8: {"pb": 3, "dc": 16, "attack": 7, "hp": 183, "dmg_low": 51, "dmg_high": 56},
9: {"pb": 4, "dc": 16, "attack": 7, "hp": 198, "dmg_low": 57, "dmg_high": 62},
10: {"pb": 4, "dc": 16, "attack": 7, "hp": 213, "dmg_low": 63, "dmg_high": 68},
11: {"pb": 4, "dc": 17, "attack": 8, "hp": 228, "dmg_low": 69, "dmg_high": 74},
12: {"pb": 4, "dc": 17, "attack": 8, "hp": 243, "dmg_low": 75, "dmg_high": 80},
13: {"pb": 5, "dc": 18, "attack": 8, "hp": 258, "dmg_low": 81, "dmg_high": 86},
14: {"pb": 5, "dc": 18, "attack": 8, "hp": 273, "dmg_low": 87, "dmg_high": 92},
15: {"pb": 5, "dc": 18, "attack": 8, "hp": 288, "dmg_low": 93, "dmg_high": 98},
16: {"pb": 5, "dc": 18, "attack": 9, "hp": 303, "dmg_low": 99, "dmg_high": 104},
17: {"pb": 6, "dc": 19, "attack": 10, "hp": 318, "dmg_low": 105, "dmg_high": 110},
18: {"pb": 6, "dc": 19, "attack": 10, "hp": 333, "dmg_low": 111, "dmg_high": 116},
19: {"pb": 6, "dc": 19, "attack": 10, "hp": 348, "dmg_low": 117, "dmg_high": 122},
20: {"pb": 6, "dc": 19, "attack": 10, "hp": 363, "dmg_low": 123, "dmg_high": 140},
}
VALID_CRS = sorted(MONSTER_STATS.keys())
# ----------------------------------------------------------------------
# Вспомогательные функции
# ----------------------------------------------------------------------
def _get_cr(level: int, severity: str) -> float:
mult = {"easy": 0.25, "moderate": 0.5, "dangerous": 0.75, "deadly": 1.0}[severity]
cr_raw = level * mult
if cr_raw < 0.125:
return 0.125
if cr_raw < 1.0:
if cr_raw < 0.1875:
return 0.125
if cr_raw < 0.375:
return 0.25
else:
return 0.5
cr_int = int(cr_raw)
if cr_int not in MONSTER_STATS:
available = [c for c in VALID_CRS if c <= cr_int]
return max(available) if available else 1
return cr_int
def _get_monster_stats(cr: float) -> Dict:
return MONSTER_STATS.get(cr, MONSTER_STATS[20])
def _get_damage(level: int, severity: str, area: bool = False) -> str:
cr = _get_cr(level, severity)
stats = _get_monster_stats(cr)
avg_damage = (stats["dmg_low"] + stats["dmg_high"]) // 2
if avg_damage < 1:
avg_damage = 1
num_dice = max(1, round(avg_damage / 5.5))
if area:
return f"{num_dice}d6"
else:
return f"{num_dice}d10"
def _derive_escape_requirements(area_shape: str, effect_type: str) -> List[str]:
if area_shape == "pit":
return ["climb_speed", "climbing_gear", "spider_climb"]
if effect_type == "restrain" and area_shape == "net":
return ["strength_check", "slashing_damage"]
if effect_type == "imprison":
return ["strength_check", "lockpick", "magic"]
return []
def _get_trap_race(environment: str, user_trap_race: Optional[str]) -> str:
if user_trap_race and user_trap_race in TRAP_RACE_CLUES:
return user_trap_race
possible = TRAP_RACES_BY_ENVIRONMENT.get(environment, ["humanoid"])
return random.choice(possible)
def _get_visual_audible_clues(trap_race: str):
clues = TRAP_RACE_CLUES.get(trap_race, TRAP_RACE_CLUES["humanoid"])
return clues["visual"], clues["audible"]
def _get_summon_type(environment: str, user_summon_type: Optional[str]) -> str:
if user_summon_type:
return user_summon_type
possible = SUMMON_TYPE_BY_ENVIRONMENT.get(environment, ["humanoid"])
return random.choice(possible)
# ----------------------------------------------------------------------
# Основная функция генерации
# ----------------------------------------------------------------------
def generate_trap(debug_description: str, requests: List[TrapRequest]) -> str:
if not requests:
requests = [{}]
results = []
for idx, req in enumerate(requests, 1):
# --- Параметры по умолчанию ---
env = req.get("environment", "dungeon").lower()
level = max(1, min(20, int(req.get("level", 1))))
trap_type = req.get("trap_type")
if trap_type not in ["mechanical", "magical", "haunted", "living"]:
trap_type = random.choice(
["mechanical", "magical", "haunted", "living"])
complexity = req.get("complexity", "simple")
if complexity not in ["simple", "complex"]:
complexity = "simple"
severity = req.get("severity", "moderate")
if severity not in ["easy", "moderate", "dangerous", "deadly"]:
severity = "moderate"
trigger_type = req.get("trigger_type")
valid_triggers = [
"pressure_plate", "trip_wire", "door_open", "lock_pick", "touch",
"time", "magical_ward", "manual", "force", "enter_area",
"looked_at"
]
if not trigger_type or trigger_type not in valid_triggers:
trigger_type = random.choice(valid_triggers)
trigger_location = req.get("trigger_location")
valid_locs = [
"floor", "wall", "ceiling", "door", "chest", "object", "corridor"
]
if not trigger_location or trigger_location not in valid_locs:
trigger_location = random.choice(valid_locs)
effect_type = req.get("effect_type")
valid_effects = [
"damage", "condition", "restrain", "imprison", "teleport",
"summon", "alarm", "delay", "environment", "curse", "flooding",
"illusion"
]
if not effect_type or effect_type not in valid_effects:
effect_type = random.choice(["damage", "condition", "restrain"])
# TODO: Можно поддержать ещё secondary effect для complex ловушек, с шансом, например, 10 или 20%, в виде:
# secondary_effect_type (те же значения, что effect_type: damage, condition, restrain, imprison, teleport, summon, alarm, delay, environment, curse, flooding, illusion)
# secondary_damage_type (если secondary_effect_type = damage)
# secondary_severity_factor (float, по умолчанию 0.5 — множитель к урону/Сл)
damage_type = req.get("damage_type")
valid_damage = [
"poison", "fire", "cold", "acid", "lightning", "bludgeoning",
"piercing", "slashing", "psychic", "necrotic", "radiant", "thunder"
]
if effect_type == "damage" and (not damage_type
or damage_type not in valid_damage):
damage_type = random.choice(valid_damage)
area_shape = req.get("area_shape")
valid_shapes = [
"single_target", "cone", "cube", "line", "sphere", "room", "pit",
"net"
]
if not area_shape or area_shape not in valid_shapes:
area_shape = random.choice(["single_target", "cone", "cube"])
duration = req.get("duration")
valid_durations = [
"instant", "persistent", "repeating", "until_escape"
]
if not duration or duration not in valid_durations:
duration = "instant"
detection_skill = req.get("detection_skill")
valid_detection = ["perception", "investigation", "arcana", "none"]
if not detection_skill or detection_skill not in valid_detection:
detection_skill = "perception"
disarm_skill = req.get("disarm_skill")
valid_disarm = [
"thieves_tools", "athletics", "arcana", "religion",
"simple_action", "none"
]
if not disarm_skill or disarm_skill not in valid_disarm:
disarm_skill = "thieves_tools"
reset = req.get("reset")
valid_reset = ["once", "manual", "automatic"]
if not reset or reset not in valid_reset:
reset = "once"
avoidable_by = req.get("avoidable_by", [])
if not isinstance(avoidable_by, list):
avoidable_by = []
valid_avoid = ["password", "specific_item", "specific_creature_type"]
avoidable_by = [a for a in avoidable_by if a in valid_avoid]
# Новые поля
trap_race = _get_trap_race(env, req.get("trap_race"))
summon_type = _get_summon_type(env, req.get("summon_type"))
disarm_fail_threshold = req.get("disarm_fail_threshold",
5 if severity == "deadly" else 0)
flooding_medium = req.get(
"flooding_medium", "water") if effect_type == "flooding" else None
illusion_type = req.get(
"illusion_type", "visual") if effect_type == "illusion" else None
illusion_save = req.get(
"illusion_save", "wisdom") if effect_type == "illusion" else None
# Рассчёт характеристик
cr = _get_cr(level, severity)
stats = _get_monster_stats(cr)
save_dc = stats["dc"]
attack_bonus = stats["attack"]
hp = stats["hp"]
damage_single = _get_damage(level, severity, area=False)
damage_area = _get_damage(
level, severity,
area=True) if area_shape != "single_target" else damage_single
init_bonus = stats["pb"] if complexity == "complex" else None
escape_req = _derive_escape_requirements(area_shape, effect_type)
visual_clues, audible_clues = _get_visual_audible_clues(trap_race)
# --- Генерация Markdown ---
lines = []
lines.append(f"### 🪤 Ловушка №{idx}")
lines.append(f"- **Локация:** {env.capitalize()}")
lines.append(
f"- **Уровень группы:** {level} | **Угроза:** {severity.capitalize()} | **Сложность:** {complexity.capitalize()}"
)
lines.append(
f"- **Тип ловушки:** {trap_type.capitalize()} | **Раса создателей:** {trap_race.capitalize()}"
)
# Триггер с указанием дальности
trigger_range = req.get("trigger_range_ft", 0)
if trigger_range > 0:
trigger_text = f"{trigger_type.replace('_', ' ').capitalize()} на расстоянии {trigger_range} футов ({trigger_location})"
else:
trigger_text = f"{trigger_type.replace('_', ' ').capitalize()} ({trigger_location})"
lines.append(f"- **Триггер:** {trigger_text}")
lines.append(
f"- **Эффект:** {effect_type.capitalize()} | **Область:** {area_shape.replace('_', ' ').capitalize()}"
)
# Размер области
area_size = AREA_SIZES.get(area_shape, 0)
if area_size > 0 and area_shape != "single_target":
lines.append(f"- **Размер области:** {area_size} футов")
lines.append(f"- **Длительность:** {duration.capitalize()}")
lines.append("")
lines.append("**Описание:**")
lines.append(
f"*{visual_clues[0].replace('_', ' ').capitalize()} и {audible_clues[0]} выдают скрытую угрозу.*"
)
lines.append("")
lines.append(
f"🔍 **Обнаружение:** Успешная проверка Мудрости ({detection_skill.capitalize()}) Сл {save_dc} позволяет заметить признаки ловушки."
)
disarm_text = ""
if disarm_skill == "thieves_tools":
disarm_text = f"Успешная проверка Ловкости (Воровские инструменты) Сл {save_dc} обезвреживает ловушку. При провале на {disarm_fail_threshold} или более ловушка срабатывает."
elif disarm_skill == "athletics":
disarm_text = f"Успешная проверка Силы (Атлетика) Сл {save_dc} позволяет сломать или заблокировать механизм."
elif disarm_skill == "arcana":
disarm_text = f"Успешная проверка Интеллекта (Магия) Сл {save_dc} или заклинание `dispel magic` (Сл {save_dc}) отключает магическую ловушку."
elif disarm_skill == "religion":
disarm_text = f"Успешная проверка Интеллекта (Религия) Сл {save_dc} позволяет стереть священную руну."
elif disarm_skill == "simple_action":
disarm_text = "Достаточно простого действия: перешагнуть, забить клин или перерезать верёвку."
else:
disarm_text = "Ловушку невозможно обезвредить; её можно только избежать."
lines.append(f"🛠️ **Обезвреживание:** {disarm_text}")
reset_text = {
"once": "Ловушка одноразовая.",
"manual": "Требуется ручная перезарядка (10 минут).",
"automatic": "Перезаряжается автоматически через 1d10 минут."
}.get(reset, "")
lines.append(f"⏳ **Перезарядка:** {reset_text}")
if avoidable_by:
lines.append(
f"✨ **Избегание:** Можно полностью избежать, если {', '.join(avoidable_by)}."
)
if escape_req:
lines.append(f"🏃 **Побег:** Требуется: {', '.join(escape_req)}.")
if complexity == "complex":
lines.append(
f"⚙️ **Сложная ловушка:** Действует на инициативе (бонус {init_bonus:+d}). В свой ход повторяет эффект. Требуется 3 успешные проверки для отключения."
)
if effect_type == "damage":
save_ability = _get_save_ability(damage_type)
if area_shape == "single_target":
lines.append(
f"💥 **Урон:** {damage_single} ({damage_type.capitalize()}) + спасбросок {save_ability} Сл {save_dc} для половины."
)
else:
lines.append(
f"💥 **Урон по области:** {damage_area} ({damage_type.capitalize()}) + спасбросок {save_ability} Сл {save_dc} для половины."
)
if damage_type in ["fire", "acid"]:
lines.append("🔥 Легковоспламеняющиеся предметы загораются.")
elif effect_type == "condition":
cond = random.choice([
"отравлен", "ослеплён", "оглушён", "ошеломлён", "напуган",
"парализован"
])
lines.append(
f"😵 **Состояние:** {cond} на 1 минуту (спасбросок Мудрости Сл {save_dc})."
)
elif effect_type == "restrain":
lines.append(
f"⛓️ **Опутывание:** Цель должна преуспеть в спасброске Ловкости Сл {save_dc}, иначе становится опутанной. Для освобождения требуется проверка Силы (Сл 15)."
)
elif effect_type == "imprison":
lines.append(
f"🔒 **Заточение:** Цель должна преуспеть в спасброске Ловкости Сл {save_dc}, иначе оказывается запертой. Для освобождения требуется проверка Силы (Сл 15) или магия."
)
elif effect_type == "flooding":
lines.append(
f"🌊 **Затопление:** Каждый раунд уровень {flooding_medium} поднимается на 1 фут. Существа в зоне должны делать спасброски Силы Сл {save_dc} или быть сметёнными."
)
elif effect_type == "illusion":
lines.append(
f"👁️ **Иллюзия ({illusion_type}):** Спасбросок {illusion_save.upper()} Сл {save_dc} иначе ложное восприятие на 1 минуту."
)
elif effect_type == "summon":
lines.append(
f"📯 **Призыв:** Появляется существо типа {summon_type} (CR {cr})."
)
elif effect_type == "teleport":
lines.append(
f"🌀 **Телепортация:** Цель переносится в другое место (спасбросок Мудрости Сл {save_dc} избегает)."
)
elif effect_type == "alarm":
lines.append(f"🔔 **Тревога:** Громкий звук оповещает обитателей.")
elif effect_type == "delay":
lines.append(
f"🚧 **Задержка:** Проход блокируется (требуется проверка Силы Сл {save_dc} чтобы пробиться)."
)
elif effect_type == "environment":
lines.append(
f"🌪️ **Изменение среды:** Пол становится скользким, появляется труднопроходимая местность и т.п."
)
elif effect_type == "curse":
lines.append(
f"☠️ **Проклятие:** Цель должна преуспеть в спасброске Мудрости Сл {save_dc} иначе получает проклятие (снимается только `remove curse`)."
)
lines.append("")
lines.append(
f"*💬 Реплика Мастера: Эта ловушка {'смертельно опасна' if severity=='deadly' else 'серьёзна' if severity=='dangerous' else 'доставит неприятности' if severity=='moderate' else 'скорее досадная помеха'} для группы {level} уровня.*"
)
results.append("\n".join(lines))
return "\n\n---\n\n".join(results)
# ----------------------------------------------------------------------
# Пример использования
# ----------------------------------------------------------------------
if __name__ == "__main__":
test_req = [
{"environment": "dungeon", "level": 5, "severity": "dangerous", "effect_type": "damage", "area_shape": "cone"},
{"environment": "swamp", "level": 12, "severity": "deadly", "complexity": "complex", "trap_type": "haunted", "effect_type": "illusion"}
]
print(generate_trap("Тест", test_req))

View File

@ -0,0 +1,139 @@
Вы правы, забыл хиты. Добавляю колонку **HP** (Hit Points) — среднее значение из диапазона `Рек. Хиты` для соответствующего CR, округлённое до целого.
Для дробных CR (0.125, 0.25, 0.5) беру данные из вашей таблицы:
- CR 0.125 → 735 → среднее 21
- CR 0.25 → 3649 → среднее 43
- CR 0.5 → 5070 → среднее 60
Для целых CR — как в таблице (беру середину диапазона, округлённую до целого).
Ниже — итоговые таблицы с хитами для каждой серьёзности.
---
## Easy (M=0.25)
| Lvl | CR | PB | DC | ATK | HP (сред) | DMG_s (сред) | DMG_a (сред) | INIT |
|-----|------|----|----|-----|-----------|--------------|--------------|------|
| 1 | 0.125 | 2 | 13 | +3 | 21 | 1d4 (2.5) | 1d4 (2.5) | 2 |
| 2 | 0.25 | 2 | 13 | +3 | 43 | 1d4 (2.5) | 1d4 (2.5) | 2 |
| 3 | 0.5 | 2 | 13 | +3 | 60 | 1d6 (3.5) | 1d4 (2.5) | 2 |
| 4 | 1 | 2 | 13 | +3 | 78 | 2d10 (11) | 2d6 (7) | 2 |
| 5 | 1 | 2 | 13 | +3 | 78 | 2d10 (11) | 2d6 (7) | 2 |
| 6 | 1 | 2 | 13 | +3 | 78 | 2d10 (11) | 2d6 (7) | 2 |
| 7 | 1 | 2 | 13 | +3 | 78 | 2d10 (11) | 2d6 (7) | 2 |
| 8 | 2 | 2 | 13 | +3 | 93 | 3d10 (16.5) | 3d6 (10.5) | 2 |
| 9 | 2 | 2 | 13 | +3 | 93 | 3d10 (16.5) | 3d6 (10.5) | 2 |
| 10 | 2 | 2 | 13 | +3 | 93 | 3d10 (16.5) | 3d6 (10.5) | 2 |
| 11 | 2 | 2 | 13 | +3 | 93 | 3d10 (16.5) | 3d6 (10.5) | 2 |
| 12 | 3 | 2 | 13 | +4 | 108 | 4d10 (22) | 4d6 (14) | 2 |
| 13 | 3 | 2 | 13 | +4 | 108 | 4d10 (22) | 4d6 (14) | 2 |
| 14 | 3 | 2 | 13 | +4 | 108 | 4d10 (22) | 4d6 (14) | 2 |
| 15 | 3 | 2 | 13 | +4 | 108 | 4d10 (22) | 4d6 (14) | 2 |
| 16 | 4 | 2 | 14 | +5 | 123 | 5d10 (27.5) | 5d6 (17.5) | 2 |
| 17 | 4 | 2 | 14 | +5 | 123 | 5d10 (27.5) | 5d6 (17.5) | 2 |
| 18 | 4 | 2 | 14 | +5 | 123 | 5d10 (27.5) | 5d6 (17.5) | 2 |
| 19 | 4 | 2 | 14 | +5 | 123 | 5d10 (27.5) | 5d6 (17.5) | 2 |
| 20 | 5 | 3 | 15 | +6 | 138 | 6d10 (33) | 6d6 (21) | 3 |
---
## Moderate (M=0.5)
| Lvl | CR | PB | DC | ATK | HP (сред) | DMG_s (сред) | DMG_a (сред) | INIT |
|-----|----|----|----|-----|-----------|--------------|--------------|------|
| 1 | 0.5 | 2 | 13 | +3 | 60 | 1d6 (3.5) | 1d4 (2.5) | 2 |
| 2 | 1 | 2 | 13 | +3 | 78 | 2d10 (11) | 2d6 (7) | 2 |
| 3 | 1 | 2 | 13 | +3 | 78 | 2d10 (11) | 2d6 (7) | 2 |
| 4 | 2 | 2 | 13 | +3 | 93 | 3d10 (16.5) | 3d6 (10.5) | 2 |
| 5 | 2 | 2 | 13 | +3 | 93 | 3d10 (16.5) | 3d6 (10.5) | 2 |
| 6 | 3 | 2 | 13 | +4 | 108 | 4d10 (22) | 4d6 (14) | 2 |
| 7 | 3 | 2 | 13 | +4 | 108 | 4d10 (22) | 4d6 (14) | 2 |
| 8 | 4 | 2 | 14 | +5 | 123 | 5d10 (27.5) | 5d6 (17.5) | 2 |
| 9 | 4 | 2 | 14 | +5 | 123 | 5d10 (27.5) | 5d6 (17.5) | 2 |
| 10 | 5 | 3 | 15 | +6 | 138 | 6d10 (33) | 6d6 (21) | 3 |
| 11 | 5 | 3 | 15 | +6 | 138 | 6d10 (33) | 6d6 (21) | 3 |
| 12 | 6 | 3 | 15 | +6 | 153 | 7d10 (38.5) | 7d6 (24.5) | 3 |
| 13 | 6 | 3 | 15 | +6 | 153 | 7d10 (38.5) | 7d6 (24.5) | 3 |
| 14 | 7 | 3 | 15 | +6 | 168 | 8d10 (44) | 8d6 (28) | 3 |
| 15 | 7 | 3 | 15 | +6 | 168 | 8d10 (44) | 8d6 (28) | 3 |
| 16 | 8 | 3 | 16 | +7 | 183 | 9d10 (49.5) | 9d6 (31.5) | 3 |
| 17 | 8 | 3 | 16 | +7 | 183 | 9d10 (49.5) | 9d6 (31.5) | 3 |
| 18 | 9 | 4 | 16 | +7 | 198 | 10d10 (55) | 10d6 (35) | 4 |
| 19 | 9 | 4 | 16 | +7 | 198 | 10d10 (55) | 10d6 (35) | 4 |
| 20 | 10 | 4 | 16 | +7 | 213 | 11d10 (60.5) | 11d6 (38.5) | 4 |
---
## Dangerous (M=0.75)
| Lvl | CR | PB | DC | ATK | HP (сред) | DMG_s (сред) | DMG_a (сред) | INIT |
|-----|----|----|----|-----|-----------|--------------|--------------|------|
| 1 | 0.5 | 2 | 13 | +3 | 60 | 1d6 (3.5) | 1d4 (2.5) | 2 |
| 2 | 1 | 2 | 13 | +3 | 78 | 2d10 (11) | 2d6 (7) | 2 |
| 3 | 2 | 2 | 13 | +3 | 93 | 3d10 (16.5) | 3d6 (10.5) | 2 |
| 4 | 3 | 2 | 13 | +4 | 108 | 4d10 (22) | 4d6 (14) | 2 |
| 5 | 3 | 2 | 13 | +4 | 108 | 4d10 (22) | 4d6 (14) | 2 |
| 6 | 4 | 2 | 14 | +5 | 123 | 5d10 (27.5) | 5d6 (17.5) | 2 |
| 7 | 5 | 3 | 15 | +6 | 138 | 6d10 (33) | 6d6 (21) | 3 |
| 8 | 6 | 3 | 15 | +6 | 153 | 7d10 (38.5) | 7d6 (24.5) | 3 |
| 9 | 6 | 3 | 15 | +6 | 153 | 7d10 (38.5) | 7d6 (24.5) | 3 |
| 10 | 7 | 3 | 15 | +6 | 168 | 8d10 (44) | 8d6 (28) | 3 |
| 11 | 8 | 3 | 16 | +7 | 183 | 9d10 (49.5) | 9d6 (31.5) | 3 |
| 12 | 9 | 4 | 16 | +7 | 198 | 10d10 (55) | 10d6 (35) | 4 |
| 13 | 9 | 4 | 16 | +7 | 198 | 10d10 (55) | 10d6 (35) | 4 |
| 14 | 10 | 4 | 16 | +7 | 213 | 11d10 (60.5) | 11d6 (38.5) | 4 |
| 15 | 11 | 4 | 17 | +8 | 228 | 12d10 (66) | 12d6 (42) | 4 |
| 16 | 12 | 4 | 17 | +8 | 243 | 13d10 (71.5) | 13d6 (45.5) | 4 |
| 17 | 12 | 4 | 17 | +8 | 243 | 13d10 (71.5) | 13d6 (45.5) | 4 |
| 18 | 13 | 5 | 18 | +8 | 258 | 14d10 (77) | 14d6 (49) | 5 |
| 19 | 14 | 5 | 18 | +8 | 273 | 15d10 (82.5) | 15d6 (52.5) | 5 |
| 20 | 15 | 5 | 18 | +8 | 288 | 16d10 (88) | 16d6 (56) | 5 |
---
## Deadly (M=1.0)
| Lvl | CR | PB | DC | ATK | HP (сред) | DMG_s (сред) | DMG_a (сред) | INIT |
|-----|----|----|----|-----|-----------|--------------|--------------|------|
| 1 | 1 | 2 | 13 | +3 | 78 | 2d10 (11) | 2d6 (7) | 2 |
| 2 | 2 | 2 | 13 | +3 | 93 | 3d10 (16.5) | 3d6 (10.5) | 2 |
| 3 | 3 | 2 | 13 | +4 | 108 | 4d10 (22) | 4d6 (14) | 2 |
| 4 | 4 | 2 | 14 | +5 | 123 | 5d10 (27.5) | 5d6 (17.5) | 2 |
| 5 | 5 | 3 | 15 | +6 | 138 | 6d10 (33) | 6d6 (21) | 3 |
| 6 | 6 | 3 | 15 | +6 | 153 | 7d10 (38.5) | 7d6 (24.5) | 3 |
| 7 | 7 | 3 | 15 | +6 | 168 | 8d10 (44) | 8d6 (28) | 3 |
| 8 | 8 | 3 | 16 | +7 | 183 | 9d10 (49.5) | 9d6 (31.5) | 3 |
| 9 | 9 | 4 | 16 | +7 | 198 | 10d10 (55) | 10d6 (35) | 4 |
| 10 | 10 | 4 | 16 | +7 | 213 | 11d10 (60.5) | 11d6 (38.5) | 4 |
| 11 | 11 | 4 | 17 | +8 | 228 | 12d10 (66) | 12d6 (42) | 4 |
| 12 | 12 | 4 | 17 | +8 | 243 | 13d10 (71.5) | 13d6 (45.5) | 4 |
| 13 | 13 | 5 | 18 | +8 | 258 | 14d10 (77) | 14d6 (49) | 5 |
| 14 | 14 | 5 | 18 | +8 | 273 | 15d10 (82.5) | 15d6 (52.5) | 5 |
| 15 | 15 | 5 | 18 | +8 | 288 | 16d10 (88) | 16d6 (56) | 5 |
| 16 | 16 | 5 | 18 | +9 | 303 | 17d10 (93.5) | 17d6 (59.5) | 5 |
| 17 | 17 | 6 | 19 | +10 | 318 | 18d10 (99) | 18d6 (63) | 6 |
| 18 | 18 | 6 | 19 | +10 | 333 | 19d10 (104.5) | 19d6 (66.5) | 6 |
| 19 | 19 | 6 | 19 | +10 | 348 | 20d10 (110) | 20d6 (70) | 6 |
| 20 | 20 | 6 | 19 | +10 | 363 | 21d10 (115.5) | 21d6 (73.5) | 6 |
---
Теперь таблицы полны: есть CR, PB, DC, ATK, HP, урон (single и area), инициатива.
Все значения согласованы с вашей таблицей монстров.
Если всё устраивает, приступаю к написанию кода `generate_trap.py`, который будет:
- По уровню и серьёзности определять CR и все характеристики из этих таблиц.
- Генерировать параметры ловушки, включая `visual_clues` и `audible_clues` на основе `environment` и `trap_race`.
- Вычислять `escape_requirements` по правилам.
- Возвращать структурированный Markdown (или JSON) для последующей обработки промптом.
Согласны? Нужны ещё правки?Отлично, таблицы готовы. Переходим к написанию кода. Я реализую `generate_trap.py` согласно согласованным таблицам и логике. Код будет содержать:
- Функции для получения CR, PB, DC, ATK, HP, урона (single/area), инициативы по уровню и серьёзности.
- Определение `trap_race` из environment (если не задана).
- Определение `summon_type` из environment (если не задан).
- Автоматический вывод `escape_requirements`.
- Генерацию `visual_clues` и `audible_clues` на основе trap_race.
- Формирование Markdown-описания.
Начну писать код. После завершения выложу его сюда для проверки.

File diff suppressed because it is too large Load Diff

65
locationTable.js Normal file
View File

@ -0,0 +1,65 @@
await dv.view("scripts/locations");
// Функция для создания ссылки на страницу
function getLocationName(p) {
let nameLink = `[[${p.file.name}|${p.file.name}]]`; // Создаем Wiki-ссылку
return nameLink;
}
// Функция для получения основной информации
function getMainInfo(p) {
let mainInfoParts = [];
if (p.appearance || p.race) {
let appearanceTitle = "";
if (p.appearance) {
appearanceTitle += `${p.appearance}`;
}
let appearanceTooltip = `<abbr title="${appearanceTitle}">View</abbr>`;
mainInfoParts.push(appearanceTooltip);
}
return mainInfoParts.join(" / ");
}
// Функция для получения потомков для массива локаций
function getDescendantLocations(dv, targetLocations, basePath) {
// Собираем все локации, которые являются потомками любых из целевых локаций
let descendantLocations = dv
.pages("#location")
// Исключаем сами целевые локации из результатов
.filter(
(p) =>
!targetLocations.includes(p.file.name) &&
dv.locations.isDescendant(dv, p, targetLocations)
)
.sort((p) => p.file.name, "asc")
.map((p) => {
let nameLink = getLocationName(p);
let mainInfo = getMainInfo(p);
let avatarImage = dv.locations.getImageLinks(p, basePath); // Передаем basePath для формирования правильного пути
return [avatarImage, nameLink, mainInfo]; // Изображение первой колонкой
});
return descendantLocations;
}
// Функция для отображения таблицы
function show_table(dv, targetLocations, basePath) {
// Отображаем таблицу с найденными локациями
dv.span("## Локации");
dv.table(
["Image", "Name", "Main Info"],
getDescendantLocations(dv, targetLocations, basePath)
); // Изменен порядок колонок
}
// Функция для запуска отображения таблицы
function run_show_table(...args) {
let dv = args[0].arg1;
let targetLocation = args[0].arg2;
let basePath = this.app.vault.adapter.basePath; // Получаем базовый путь к хранилищу
show_table(dv, targetLocation, basePath);
}
run_show_table(input);

133
locationTypesList.js Normal file
View File

@ -0,0 +1,133 @@
//await dv.view("scripts/locations");
// Функция для создания ссылки на страницу
function getLocationName(p) {
let nameLink = `[[${p.file.name}|${p.file.name}]]`; // Создаем Wiki-ссылку
return nameLink;
}
// Функция для получения основной информации
function getMainInfo(p) {
let mainInfoParts = [];
if (p.appearance || p.race) {
let appearanceTitle = "";
if (p.appearance) {
appearanceTitle += `${p.appearance}`;
}
let appearanceTooltip = `<abbr title="${appearanceTitle}">View</abbr>`;
mainInfoParts.push(appearanceTooltip);
}
return mainInfoParts.join(" / ");
}
// Функция для получения потомков для массива локаций с дополнительными фильтрами
function getDescendantLocationsFilteredByTypeAndGoods(
dv,
targetLocations,
locationTypesArray,
goodsAndServicesArray,
basePath
) {
// Собираем все локации, которые являются потомками любых из целевых локаций
let descendantLocations = dv
.pages("#location")
// Применяем фильтры
.where((p) => {
// Проверяем, что локация не в списке целевых
if (targetLocations.includes(p.file.name)) {
return false;
}
// Проверяем, является ли локация потомком любой из целевых
if (!dv.locations.isDescendant(dv, p, targetLocations)) {
return false;
}
if (p.file.name.includes("Сэнд")) {
let y = 1;
}
// Фильтр по location-type
let locationTypeField = p["location-types"];
let locationTypes = [];
if (Array.isArray(locationTypeField)) {
locationTypes = locationTypeField;
} else if (typeof locationTypeField === "string") {
locationTypes = [locationTypeField];
}
let locationTypeMatch = locationTypesArray.some((type) =>
locationTypes.includes(type)
);
if (!locationTypeMatch) {
return false;
}
// Если goodsAndServicesArray пустой, пропускаем этот фильтр
if (goodsAndServicesArray.length === 0) {
return true;
}
// Фильтр по goods-and-services
let goodsServicesField = p["goods-and-services"];
let goodsServicesList = [];
if (Array.isArray(goodsServicesField)) {
goodsServicesList = goodsServicesField;
} else if (typeof goodsServicesField === "string") {
goodsServicesList = [goodsServicesField];
}
let goodsAndServicesMatch = goodsAndServicesArray.some((service) =>
goodsServicesList.includes(service)
);
return goodsAndServicesMatch;
})
.sort((p) => p.file.name, "asc")
.map((p) => {
let nameLink = getLocationName(p);
// Добавляем тип локации и товары/услуги в скобках после имени
let locationTypeField = p["location-types"];
let locationTypes = Array.isArray(locationTypeField)
? locationTypeField.join(", ")
: locationTypeField;
let goodsServicesField = p["goods-and-services"];
let goodsServices = Array.isArray(goodsServicesField)
? goodsServicesField.join(", ")
: goodsServicesField;
let nameWithDetails = `${nameLink} (type: ${locationTypes}; goods and services: ${goodsServices})`;
let avatarImage = dv.locations.getImageLinks(p, basePath); // Передаем basePath для формирования правильного пути
return [avatarImage, nameWithDetails]; // Изображение первой колонкой
});
return descendantLocations;
}
// Функция для отображения таблицы
function show_table(
dv,
targetLocations,
locationTypesArray,
goodsAndServicesArray,
basePath
) {
// Отображаем таблицу с найденными локациями
dv.table(
["Image", "Name (type; goods and services)"],
getDescendantLocationsFilteredByTypeAndGoods(
dv,
targetLocations,
locationTypesArray,
goodsAndServicesArray,
basePath
)
);
}
dv.locationTypesList = {
show_table,
};

80
locations.js Normal file
View File

@ -0,0 +1,80 @@
// Функция для получения ссылок на изображения
function getImageLinks(p, basePath) {
let avatar = p["avatar-image"];
if (avatar && avatar.path) {
let imagePath = `${basePath}/${avatar.path}`; // Используем базовый путь хранилища
return `<div style="width: 70px; height: 70px; display: flex; align-items: center; justify-content: center; overflow: hidden;">
<img src="file:///${imagePath}" style="width: 100%; height: 100%; object-fit: cover;">
</div>`;
}
return ""; // Возвращаем пустую строку, если изображения нет
}
// Рекурсивная функция для проверки, является ли страница потомком любой из целевых локаций
function isDescendant(dv, page, targetNames, visited = new Set()) {
if (!page || visited.has(page.file.path)) {
return false;
}
visited.add(page.file.path);
// Если страница совпадает с любой из целевых, она не считается своим потомком
if (targetNames.includes(page.file.name)) {
return true;
}
// Получаем родительские регионы
let parents = page["parent-region"];
if (!parents) {
return false;
}
if (!Array.isArray(parents)) {
parents = [parents];
}
// Рекурсивно проверяем каждого родителя
for (let parent of parents) {
let parentPage = null;
if (typeof parent === "object" && parent.path) {
parentPage = dv.page(parent.path);
} else if (typeof parent === "string") {
parentPage = dv.page(parent);
} else {
parentPage = null;
}
if (parentPage && isDescendant(dv, parentPage, targetNames, visited)) {
return true;
}
}
return false;
}
function regionMatches(dv, regionsList, pageRegions) {
if (pageRegions !== null) {
if (!Array.isArray(pageRegions)) {
pageRegions = [pageRegions];
}
return pageRegions.some(pageRegion => {
let regionPage = null;
if (typeof pageRegion === 'object' && pageRegion.hasOwnProperty('path')) {
regionPage = dv.page(pageRegion.path);
} else if (typeof pageRegion === 'string') {
regionPage = dv.page(pageRegion);
} else {
return regionsList.includes(pageRegion);
}
return isDescendant(dv, regionPage, regionsList);
});
} else {
return false;
}
}
dv.locations = {
getImageLinks,
isDescendant,
regionMatches,
};

34
magicItemShops.js Normal file
View File

@ -0,0 +1,34 @@
await dv.view("scripts/utils");
await dv.view("scripts/locations");
await dv.view("scripts/locationTypesList");
// Асинхронная функция для запуска отображения таблицы
async function run_show_table(...args) {
let dv = args[0].arg1;
let targetLocations = args[0].arg2; // Предполагается, что это массив целевых локаций
let distributeMagicItemsTemplateName = args[0].arg3; // Предполагается, что это массив целевых локаций
let title = "Места продажи магических предметов";
const locationTypesArray = ["Magic Item Shop", "Magical Academy", "Magic School", "Mages Guild", "Enchanter's Tower",
"Druid's Grove", "Market", "Secret Hideouts", "Alchemist"];
let goodsAndServicesArray = ["Scrolls", "Potions and Oils", "Ammunition", "Weapons", "Armor and Shields",
"Clothing", "Wands, Staffs, and Rods", "Jewelry", "Books", "Musical Instruments", "Transportation"];
let basePath = this.app.vault.adapter.basePath; // Получаем базовый путь к хранилищу
dv.span(`## ${title}`);
// Вставляем содержимое шаблона-заметки
await dv.utils.displayNoteContent(dv, distributeMagicItemsTemplateName);
// Отображаем таблицу с локациями
dv.locationTypesList.show_table(
dv,
targetLocations,
locationTypesArray,
goodsAndServicesArray,
basePath
);
}
run_show_table(input);

View File

@ -0,0 +1,33 @@
@echo off
setlocal enabledelayedexpansion
set root_folder=%~dp0
set output=%root_folder%files_list.txt
if exist "%output%" del "%output%"
call :process_folder "%root_folder%"
echo Готово, файл files_list.txt создан в %root_folder%
pause
exit /b
:process_folder
set "folder=%~1"
echo Обработка папки: %folder%
pushd "%folder%"
rem Перебираем файлы .md в лексикографическом порядке
for /f "delims=" %%F in ('dir /b /on *.md 2^>nul') do (
rem Выводим имя файла без расширения
for %%I in ("%%F") do echo %%~nI>>"%output%"
)
popd
rem Рекурсивно вызываем для всех подпапок
for /d %%D in ("%folder%\*") do (
call :process_folder "%%D"
)
exit /b

32
mercenariesHirePlaces.js Normal file
View File

@ -0,0 +1,32 @@
await dv.view("scripts/utils");
await dv.view("scripts/locations");
await dv.view("scripts/locationTypesList");
// Асинхронная функция для запуска отображения таблицы
async function run_show_table(...args) {
let dv = args[0].arg1;
let targetLocations = args[0].arg2; // Предполагается, что это массив целевых локаций
let distributeMagicItemsTemplateName = args[0].arg3; // Предполагается, что это массив целевых локаций
let title = "Места найма наёмников";
const locationTypesArray = ["Tavern", "Guild Hall", "Inn", "Arena", "Barracks", "Port", "Training Hall", "Secret Hideout", "Thieves' Guild", "Dock", "Training Grounds", "Circus", "Caravanserai"];
let goodsAndServicesArray = [];
let basePath = this.app.vault.adapter.basePath; // Получаем базовый путь к хранилищу
dv.span(`## ${title}`);
// Вставляем содержимое шаблона-заметки
await dv.utils.displayNoteContent(dv, distributeMagicItemsTemplateName);
// Отображаем таблицу с локациями
dv.locationTypesList.show_table(
dv,
targetLocations,
locationTypesArray,
goodsAndServicesArray,
basePath
);
}
run_show_table(input);

4
merge_markdown.bat Normal file
View File

@ -0,0 +1,4 @@
@echo off
powershell -NoProfile -Command "Get-ChildItem -Path $PWD -Recurse -Filter *.md | ForEach-Object { '# ' + $_.FullName; Get-Content $_.FullName -Encoding utf8; '' } | Set-Content -Path \"$PWD\merged.md\" -Encoding utf8"
echo Объединение завершено, файл merged.md создан в UTF-8 кодировке.
pause

135
npcTable.js Normal file
View File

@ -0,0 +1,135 @@
await dv.view("scripts/locations");
const path_module = require('path');
function getNPCName(p) {
let nameLink = `[[${p.file.name}|${p.file.name}]]`; // Создаем Wiki-ссылку
let alive = p.alive == undefined || !!p.alive;
if (!alive) {
nameLink += `<span style="color:red"> (D)</span>`;
}
return nameLink;
}
function getClassAndLevel(p) {
const statsArray = p.stats ? p.stats.split(';') : [];
if (statsArray.length > 0) {
const firstStat = statsArray[0];
const colonIndex = firstStat.indexOf(':');
return colonIndex !== -1 ? firstStat.slice(colonIndex + 1).trim() : firstStat;
}
return null;
}
function getMainInfo(p) {
let age = p.birthday ? (1491 - p.birthday) : null;
let mainInfoParts = [];
if (p.appearance || p.race) {
// specialization
let appearanceTitle = "";
if (p.race) {
appearanceTitle += `Раса: ${p.race}. `;
}
if (age) {
appearanceTitle += `Возраст: ${age} лет. `;
}
if (p.profession) {
appearanceTitle += `Профессия: ${p.profession}. `;
}
if (p.appearance) {
appearanceTitle += `${p.appearance}`;
}
let appearanceTooltip = `<abbr title="${appearanceTitle}">View</abbr>`;
mainInfoParts.push(appearanceTooltip);
}
if (p.personality) {
let personalityTooltip = `<abbr title="${p.personality}">Pers</abbr>`;
mainInfoParts.push(personalityTooltip);
}
if (p.biography) {
let biographyTooltip = `<abbr title="${p.biography}">Bio</abbr>`;
mainInfoParts.push(biographyTooltip);
}
if (p.quests) {
let questsTooltip = `<abbr title="${p.quests}">Quests</abbr>`;
mainInfoParts.push(questsTooltip);
}
return mainInfoParts.join(" / ");
}
function listProperty(property) {
if (!property) {
return "";
}
// Если это массив, разделяем элементы
if (Array.isArray(property)) {
let links = [];
let otherItems = [];
property.forEach(item => {
if (item) {
if (typeof item === 'string') {
otherItems.push(item);
}
else if (typeof item === 'object')
{
links.push(item);
}
}
});
// Собираем ссылочные элементы и создаём tooltip для остальных
let tooltip = otherItems.length > 0 ? `<abbr title="${otherItems.join(', ')}">Equip</abbr>` : "";
// Объединяем ссылки и tooltip
return [...links, tooltip].filter(Boolean).join(", ");
} else {
// Если это не массив, просто возвращаем как есть (и проверяем на тип строки)
return typeof property === 'string' ? `<abbr title="${property}">Equip</abbr>` : "";
}
}
function listAffiliation(affiliation) {
if (!affiliation) {
return "";
}
if (Array.isArray(affiliation)) {
return affiliation.filter(Boolean).join(", ");
} else {
// Если это не массив, просто возвращаем как есть (и проверяем на тип строки)
return typeof affiliation === 'string' ? affiliation : "";
}
}
// Функция для отображения таблицы
function show_table(dv, regionsList, basePath) {
dv.span("## Персонажи");
dv.table(["Image", "Name", "Stat", "Main Info", "Affiliation", "Assets", "Loc"],
dv.pages("#npc")
.filter(p => p.domicile && dv.locations.regionMatches(dv, regionsList, p.domicile))
.sort(p => p.file.name, 'asc') // Сортировка по имени файла в порядке возрастания
.map(p => {
let nameLink = getNPCName(p);
let stats = getClassAndLevel(p);
let mainInfo = getMainInfo(p);
let avatarImage = dv.locations.getImageLinks(p, basePath); // Передаем basePath для формирования правильного пути
return [avatarImage, nameLink, stats, mainInfo, listAffiliation(p.affiliation), listProperty(p.possessions), p.location]; // Аватар первой колонкой
})
);
}
// Функция для запуска отображения таблицы
function run_show_table(...args) {
let dv = args[0].arg1;
let regionsList = args[0].arg2;
let basePath = this.app.vault.adapter.basePath; // Получаем базовый путь к хранилищу
show_table(dv, regionsList, basePath);
}
run_show_table(input);

27
replace_field_name.py Normal file
View File

@ -0,0 +1,27 @@
from pathlib import Path
tag = "location"
old_name = "inhabitants"
new_name = "demographics"
# Определяем путь к родительской директории относительно местоположения скрипта
parent_directory = Path(__file__).resolve().parent.parent
# Проходим рекурсивно по всем .md файлам в родительской директории
for md_file in parent_directory.rglob('*.md'):
try:
# Читаем содержимое файла
content = md_file.read_text(encoding='utf-8')
# Проверяем, содержит ли файл тег #npc
if ("#" + tag) in content or f'- "{tag}"' in content or f'- {tag}' in content:
# Заменяем все вхождения 'regions' на 'domicile'
if ("\"" + old_name + "\"" in content):
new_content = content.replace("\"" + old_name + "\"", "\"" + new_name + "\"")
else:
new_content = content.replace(old_name, new_name)
# Записываем обновленное содержимое обратно в файл
md_file.write_text(new_content, encoding='utf-8')
print(f'Обновлен файл: {md_file}')
except Exception as e:
print(f'Ошибка при обработке файла {md_file}: {e}')

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
PyYaml
pathspec

56
showLocationTabs.js Normal file
View File

@ -0,0 +1,56 @@
async function show_tabs(dv, info) {
let output = "~~~tabs\n";
// Tab 1: Info
output += "tab: Info\n";
output += info;
output += "\n";
// Tab 2: Locations
output += "tab: Locations\n";
output += "```dataviewjs\n";
const locationScript = "scripts/locationTable";
output += `await dv.view("${locationScript}", { arg1: dv, arg2: ["${dv.current().file.name}"] });\n`;
output += "```\n";
// Tab 3: NPCs
output += "tab: NPCs\n";
output += "```dataviewjs\n";
const npcScript = "scripts/npcTable";
output += `await dv.view("${npcScript}", { arg1: dv, arg2: ["${dv.current().file.name}"] });\n`;
output += "```\n";
// Tab 4: Item Shops
output += "tab: Item Shops\n";
output += "```dataviewjs\n";
const itemShopsScript = "scripts/magicItemShops";
output += `await dv.view("${itemShopsScript}", { arg1: dv, arg2: ["${dv.current().file.name}"], arg3: "Distribute items to stores (COT)" });\n`;
output += "```\n";
// Tab 5: Scroll Shops
output += "tab: Scroll Shops\n";
output += "```dataviewjs\n";
const scrollShopsScript = "scripts/spellScrollsShops";
output += `await dv.view("${scrollShopsScript}", { arg1: dv, arg2: ["${dv.current().file.name}"], arg3: "Distribute spell scrolls to stores (COT)" });\n`;
output += "```\n";
// Tab 6: Hiring Locations
output += "tab: Hiring Locations\n";
output += "```dataviewjs\n";
const hiringScript = "scripts/mercenariesHirePlaces";
output += `await dv.view("${hiringScript}", { arg1: dv, arg2: ["${dv.current().file.name}"], arg3: "Distribute mercenaries to hire places (COT)" });\n`;
output += "```\n";
output += "~~~\n";
// Выводим сформированный текст
await dv.paragraph(output);
}
function run_show_tabs(...args) {
let dv = args[0].arg1;
let info = args[0].arg2;
show_tabs(dv, info);
}
await run_show_tabs(input);

34
spellScrollsShops.js Normal file
View File

@ -0,0 +1,34 @@
await dv.view("scripts/utils");
await dv.view("scripts/locations");
await dv.view("scripts/locationTypesList");
// Асинхронная функция для запуска отображения таблицы
async function run_show_table(...args) {
let dv = args[0].arg1;
let targetLocations = args[0].arg2; // Предполагается, что это массив целевых локаций
let distributeMagicItemsTemplateName = args[0].arg3; // Предполагается, что это массив целевых локаций
let title = "Места продажи свитков заклинаний";
const locationTypesArray = ["Magic Item Shop", "Magical Academy", "Magic School", "Mages Guild", "Enchanter's Tower",
"Temple", "Shrine", "Druid's Grove", "Market", "Secret Hideouts"];
let goodsAndServicesArray = ["Scrolls"];
let basePath = this.app.vault.adapter.basePath; // Получаем базовый путь к хранилищу
dv.span(`## ${title}`);
// Вставляем содержимое шаблона-заметки
await dv.utils.displayNoteContent(dv, distributeMagicItemsTemplateName);
// Отображаем таблицу с локациями
dv.locationTypesList.show_table(
dv,
targetLocations,
locationTypesArray,
goodsAndServicesArray,
basePath
);
}
run_show_table(input);

175
sync_fields.py Normal file
View File

@ -0,0 +1,175 @@
import os
import re
import yaml
# Константа для определения тегов
TAG_PREFIX = "#"
TAG = "#location" # Искать и удалять только этот тег
TEMPLATE_FOLDER = "Locations, Factions, Religions"
# Определяем путь к текущему скрипту
script_dir = os.path.dirname(os.path.abspath(__file__))
# Путь к папке с вашими заметками Obsidian Vault (относительно скрипта)
vault_folder = os.path.join(script_dir, "../")
# Путь к файлу-шаблону с полями
template_file = os.path.join(
script_dir, f"../Prompts/{TEMPLATE_FOLDER}/Fields for {TAG.lstrip(TAG_PREFIX).capitalize()}.md"
)
# Путь к файлу .gitignore
gitignore_file = os.path.join(script_dir, "../.gitignore")
# Загружаем правила из .gitignore
def load_gitignore(filepath):
if not os.path.exists(filepath):
return None
with open(filepath, "r", encoding="utf-8") as file:
from pathspec import PathSpec
return PathSpec.from_lines("gitwildmatch", file)
gitignore_spec = load_gitignore(gitignore_file)
def is_ignored(filepath):
"""Проверяет, игнорируется ли файл или папка."""
if gitignore_spec is None:
return False
relative_path = os.path.relpath(filepath, vault_folder)
return gitignore_spec.match_file(relative_path)
def load_template_fields(filepath):
"""Загружает список полей и их порядок из файла-шаблона."""
with open(filepath, "r", encoding="utf-8") as file:
content = file.read()
# Извлекаем YAML из шаблона
yaml_match = re.match(r"---\n(.*?)\n---", content, re.S)
if not yaml_match:
raise ValueError(f"YAML-часть не найдена в шаблоне: {filepath}")
yaml_content = yaml.safe_load(yaml_match.group(1))
return yaml_content.keys()
# Загружаем поля из шаблона
try:
template_fields = list(load_template_fields(template_file))
except Exception as e:
print(f"Ошибка загрузки шаблона: {e}")
exit(1)
class NoAliasDumper(yaml.Dumper):
"""Кастомный Dumper для управления выводом YAML."""
def ignore_aliases(self, data):
return True
def represent_str(dumper, data):
"""Обработчик для записи строк значений."""
if dumper.alias_key is not None:
# Если это ключ - без кавычек
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style='')
# Если это значение - в кавычках
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style='"')
# Регистрируем кастомный обработчик для строк
yaml.add_representer(str, represent_str, Dumper=NoAliasDumper)
def represent_none(dumper, _):
"""Представляет None как пустую строку."""
return dumper.represent_scalar('tag:yaml.org,2002:null', '')
yaml.add_representer(type(None), represent_none, Dumper=NoAliasDumper)
def extract_and_clean_tags(content):
"""Удаляет строку с определённым тегом (TAG) и возвращает обновлённое тело и сам тег."""
lines = content.splitlines()
found_tag = None
cleaned_lines = []
for line in lines:
if line.strip() == TAG: # Удаляем строку с точным соответствием тегу
found_tag = TAG.lstrip(TAG_PREFIX)
else:
cleaned_lines.append(line)
return found_tag, "\n".join(cleaned_lines)
def process_file(filepath):
"""Обрабатывает файл, добавляя и упорядочивая поля."""
with open(filepath, "r", encoding="utf-8") as file:
content = file.read()
# Проверяем наличие тега как в теле, так и в YAML
tag_found = False
# Ищем YAML в начале файла
yaml_match = re.match(r"---\n(.*?)\n---", content, re.S)
if yaml_match:
yaml_content = yaml.safe_load(yaml_match.group(1)) or {}
body_start = yaml_match.end()
# Проверяем наличие тега в YAML
if yaml_content.get('tags'):
if isinstance(yaml_content['tags'], list):
if TAG.lstrip(TAG_PREFIX) in yaml_content['tags']:
tag_found = True
elif isinstance(yaml_content['tags'], str):
if TAG.lstrip(TAG_PREFIX) == yaml_content['tags']:
tag_found = True
# Преобразуем строку в список
yaml_content['tags'] = [yaml_content['tags']]
else:
yaml_content = {}
body_start = 0
# Проверяем наличие тега в теле документа
found_tag, cleaned_content = extract_and_clean_tags(content[body_start:])
if found_tag:
tag_found = True
# Добавляем найденный тег в YAML
yaml_content.setdefault("tags", [])
if found_tag not in yaml_content["tags"]:
yaml_content["tags"].append(found_tag)
# Если тег найден где-либо (в YAML или в теле), обрабатываем файл
if tag_found:
# Формируем новый YAML с учетом порядка из шаблона
ordered_yaml = {key: yaml_content.get(key) for key in template_fields}
# Добавляем лишние (не указанные в шаблоне) поля в конец, сохраняя их порядок
extra_fields = {key: value for key, value in yaml_content.items()
if key not in template_fields}
final_yaml = {**ordered_yaml, **extra_fields}
# Обновляем содержимое файла
yaml_content = yaml.dump(final_yaml, Dumper=NoAliasDumper, default_flow_style=False, allow_unicode=True, sort_keys=False, width=float('inf'))
cleaned_content = cleaned_content.lstrip() # Убираем начальные пробелы и переносы строк
new_content = f"---\n{yaml_content}---\n\n{cleaned_content}"
# Записываем обновлённый файл
with open(filepath, "w", encoding="utf-8") as file:
file.write(new_content)
print(f"Обработан файл: {filepath}")
# Обходим все файлы в папке Vault
for root, dirs, files in os.walk(vault_folder):
# Исключаем директории, игнорируемые .gitignore
dirs[:] = [d for d in dirs if not is_ignored(os.path.join(root, d))]
for file in files:
filepath = os.path.join(root, file)
# Проверяем, игнорируется ли файл
if not is_ignored(filepath) and file.endswith(".md"): # Обрабатываем только Markdown-файлы
process_file(filepath)

319
timeline-widget.js Normal file
View File

@ -0,0 +1,319 @@
await dv.view("scripts/timeline");
await dv.view("scripts/locations");
// Уровень видимости "глобальный" для этого файла
let locationCounter = 0; // счётчик, если нужно что-то уникальное
async function updateTimeline(dv) {
// Удаляем предыдущую диаграмму
const oldContainer = dv.container.querySelector(".mermaid-timeline");
if (oldContainer) {
oldContainer.remove();
}
const locations = Array.from(dv.container.querySelectorAll(".location")).map(
(input) => input.value
);
const characters = Array.from(
dv.container.querySelectorAll(".character")
).map((input) => input.value);
const currentDate = dv.container.querySelector(".currentDate").value;
const startDate = dv.container.querySelector(".startDate").value;
const endDate = dv.container.querySelector(".endDate").value;
const pcsParticipationOnly = dv.container.querySelector(
".pcsParticipationOnly"
).checked;
const includeRegionsParticipants = dv.container.querySelector(
".includeRegionsParticipants"
).checked;
// Вызываем рендеринг таймлайна (важно, чтобы dv был виден в этой функции)
dv.timeline.showTimeline(
dv,
locations,
characters,
currentDate,
startDate,
endDate,
pcsParticipationOnly,
includeRegionsParticipants
);
}
/**
* Заполняем общий <locationsDataList> всеми заметками.
*/
function setupAutocomplete(dv, locationsDataList, tag) {
// Получаем список всех заметок
const notes = dv.pages(tag).file.name; // это массив названий файлов
// Очищаем текущие опции (на случай повторных вызовов)
locationsDataList.innerHTML = "";
notes.forEach((note) => {
const option = document.createElement("option");
option.value = note;
locationsDataList.appendChild(option);
});
}
/**
* Добавляет новый блок с <input> для локации
*/
function addListFormField(container, inputName, dataListName) {
// locationsDataList
const div = document.createElement("div");
div.className = "locationContainer";
// Обратите внимание, указываем list="locationsDataList", а <datalist id="locationsDataList">
// будет один на всю форму
div.innerHTML = `
<input type="text" class="${inputName}"
placeholder="Введите название" list="${dataListName}" />
<button type="button" class="removeDiv">Удалить</button>
`;
container.appendChild(div);
// При удалении блока убираем сам <div>
div.querySelector(".removeDiv").addEventListener("click", () => {
div.remove();
});
}
async function run_show_timeline_widget(...args) {
let dv = args[0].arg1; // Чтобы не путаться, явно назовём dvParam
let defaultLocations = args[0].arg2 || [];
let defaultCurrentDate = args[0].arg3 || "";
let defaultStartDate = args[0].arg4 || "";
let defaultEndDate = args[0].arg5 || "";
let defaultPcsParticipationOnly = args[0].arg6 || false;
let defaultIncludeRegionsParticipants = args[0].arg7 || false;
let fullWidth = args[0].arg8 || false;
// Добавляем стили для полной ширины (чтобы диаграмму можно было растянуть максимально), если fullWidth = true
const markdownPreviewSizer = dv.container.closest(
".markdown-preview-view .markdown-preview-sizer"
);
if (markdownPreviewSizer) {
if (fullWidth) {
markdownPreviewSizer.style.maxWidth = "none";
markdownPreviewSizer.style.margin = "0 1em";
} else {
markdownPreviewSizer.style.maxWidth = "";
markdownPreviewSizer.style.margin = "";
}
}
// Сохраним глобально dv, если нужно использовать вне этой функции
window.dv = dv;
// Формируем HTML формы
dv.container.innerHTML = `
<style>
/* Главный контейнер формы */
.form-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 0.5rem;
max-width: 800px;
margin: 0 auto;
}
/* Контейнер для полей ввода */
.field-container {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
/* Общий стиль для кнопок */
button {
padding: 0.3rem 0.8rem;
font-size: 0.9rem;
border: 1px solid #ccc;
border-radius: 4px;
background-color: #f9f9f9;
cursor: pointer;
}
button:hover {
background-color: #e0e0e0;
}
/* Группы полей */
.form-group {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding: 0.5rem;
border: 1px solid #eee;
border-radius: 4px;
}
.form-group-2 {
display: flex;
flex-direction: column;
gap: 0.3em;
}
/* Поля ввода */
.form-input {
padding: 0.3rem;
font-size: 0.9rem;
border: 1px solid #ccc;
border-radius: 4px;
}
/* Чекбоксы */
.form-checkbox {
margin-left: 0.3rem;
}
/* Кнопки добавления */
.add-button {
align-self: flex-start;
margin-top: 0.3rem;
}
/* Добавьте контейнер для кнопки обновления */
.update-button-container {
grid-column: 1 / -1;
display: flex;
justify-content: center;
margin: 0.3rem 0 0.8rem 0;
}
.update-button {
width: 200px;
}
/* Метки полей */
label {
font-size: 0.9rem;
margin-bottom: 0.2rem;
}
.update-button-container {
grid-column: 1 / -1;
display: flex;
justify-content: center;
gap: 2em;
margin: 0.3rem 0 0.8rem 0;
}
</style>
<form id="timelineForm" class="form-container">
<div class="form-group">
<div class="form-group-2">
<label>Локации:</label>
<div class="locationsContainer field-container"></div>
<button type="button" class="addLocation add-button">Добавить локацию</button>
</div>
</div>
<div class="form-group">
<div class="form-group-2">
<label>Участвующие персонажи:</label>
<div class="characters field-container"></div>
<button type="button" class="addCharacter add-button">Добавить персонажа</button>
</div>
<div class="form-group-2">
<label for="pcsParticipationOnly">Только с участием ИП:</label>
<input type="checkbox" name="pcsParticipationOnly"
${
defaultPcsParticipationOnly ? "checked" : ""
} class="pcsParticipationOnly form-checkbox" />
</div>
<div class="form-group-2">
<label for="includeRegionsParticipants">Включить regions и participants:</label>
<input type="checkbox" name="includeRegionsParticipants"
${
defaultIncludeRegionsParticipants ? "checked" : ""
} class="includeRegionsParticipants form-checkbox" />
</div>
</div>
<div class="form-group">
<div class="form-group-2">
<label for="currentDate">Текущая дата:</label>
<input type="text" name="currentDate" value="${defaultCurrentDate}" class="currentDate form-input" />
</div>
<div class="form-group-2">
<label for="startDate">Дата начала:</label>
<input type="text" name="startDate" value="${defaultStartDate}" class="startDate form-input" />
</div>
<div class="form-group-2">
<label for="endDate">Дата окончания:</label>
<input type="text" name="endDate" value="${defaultEndDate}" class="endDate form-input" />
</div>
</div>
<datalist id="locationsDataList"></datalist>
<datalist id="charactersDataList"></datalist>
<div class="update-button-container">
<button type="button" class="updateButton update-button">Обновить график</button>
<button type="button" class="copyMermaidButton update-button">Копировать mermaid</button>
</div>
</form>
<div class="timelineOutput"></div>
`;
const locationsContainer = dv.container.querySelector(".locationsContainer");
const addLocationButton = dv.container.querySelector(".addLocation");
const locationsDataList = dv.container.querySelector("#locationsDataList"); // общий datalist
const charactersContainer = dv.container.querySelector(".characters");
const addCharacterButton = dv.container.querySelector(".addCharacter");
const charactersDataList = dv.container.querySelector("#charactersDataList"); // общий datalist
// Заполним общий datalist всеми заметками (автозаполнение)
setupAutocomplete(dv, locationsDataList, "#location");
setupAutocomplete(dv, charactersDataList, "#npc");
// Инициализируем поля по умолчанию
defaultLocations.forEach((location) => {
addListFormField(locationsContainer, "location", "locationsDataList");
locationsContainer.lastChild.querySelector(".location").value = location;
});
// Добавляем новое поле по кнопке
addLocationButton.addEventListener("click", () => {
addListFormField(locationsContainer, "location", "locationsDataList");
});
// Добавляем новое поле по кнопке
addCharacterButton.addEventListener("click", () => {
addListFormField(charactersContainer, "character", "charactersDataList");
});
// Обновляем график при нажатии на кнопку
dv.container
.querySelector(".updateButton")
.addEventListener("click", () => updateTimeline(dv));
dv.container
.querySelector(".copyMermaidButton")
.addEventListener("click", () => {
if (window.lastGeneratedMermaidCode) {
navigator.clipboard
.writeText(window.lastGeneratedMermaidCode)
.then(() => {
// Можно добавить визуальную обратную связь
const button = dv.container.querySelector(".copyMermaidButton");
const originalText = button.textContent;
button.textContent = "Скопировано!";
setTimeout(() => {
button.textContent = originalText;
}, 1000);
})
.catch((err) => {
console.error("Ошибка при копировании: ", err);
});
}
});
updateTimeline(dv);
}
await run_show_timeline_widget(input);

577
timeline.js Normal file
View File

@ -0,0 +1,577 @@
await dv.view("scripts/locations");
const path = require("path");
// В начале файла добавить:
window.lastGeneratedMermaidCode = ""; // Глобальная переменная для хранения последнего сгенерированного кода
// Функция, переводящая дату "ГГГГ.ММ.ДД" (с 30 днями в месяце) в число.
function customDateToNumber(str) {
let [y, m, d] = str.split("/");
y = parseInt(y, 10);
m = parseInt(m, 10);
d = parseInt(d, 10);
return (y * 12 + (m - 1)) * 30 + (d - 1);
}
function charactersMatches(dv, charactersList, pCharacters) {
if (pCharacters !== null) {
if (!Array.isArray(pCharacters)) {
pCharacters = [pCharacters];
}
return pCharacters.some((pCharacter) => {
if (typeof pCharacter === "object" && pCharacter.hasOwnProperty("path")) {
const fileNameWithoutExt = path.basename(
pCharacter.path,
path.extname(pCharacter.path)
);
return charactersList.includes(fileNameWithoutExt);
} else {
return charactersList.includes(pCharacter);
}
});
} else {
return false;
}
}
function pageWithCorrectFields(dv, p, locations, characters) {
if (!dv.locations.regionMatches(dv, locations, p.regions)) return false;
if (
!characters ||
!characters.length ||
charactersMatches(dv, characters, p.participants)
)
return true;
return false;
}
// Функция проверки, попадает ли дата в заданный диапазон.
function isInRange(p, startStr, endStr) {
const dateStr = p.date;
if (!dateStr) return false;
if (p.file.folder === "Templates") return false;
if (typeof dateStr !== "string") return false;
let dateNum = customDateToNumber(dateStr);
let startNum = customDateToNumber(startStr);
let endNum = customDateToNumber(endStr);
return dateNum >= startNum && dateNum <= endNum;
}
/**
* Позволяет «тащить» (drag) содержимое контейнера .mermaid-timeline
* при зажатой клавише Ctrl + ЛКМ.
* Реализовано через прокрутку (scroll) контейнера.
*/
function enableDiagramDragging(dv) {
const container = dv.container.querySelector(".mermaid-timeline");
if (!container) return;
// Настраиваем стили контейнера:
container.style.position = "relative";
container.style.width = "100%"; // Можно зафиксировать ширину, если нужно
container.style.height = "660px"; // или любую другую высоту
container.style.overflow = "auto"; // Показываем полосы прокрутки
container.style.border = "1px solid #ccc"; // Для наглядности
container.style.cursor = "grab"; // Курсов при наведении
// container.style.backgroundColor = "green"; // Курсов при наведении
let isDragging = false;
let startX, startY; // Координаты точки, где нажали мышь
let scrollLeft, scrollTop; // Текущие значения прокрутки в момент начала «захвата»
// Когда нажимаем мышь + Ctrl на контейнере
container.addEventListener("mousedown", (e) => {
// Проверяем, что нажата ЛКМ (button === 0)
if (e.button === 2) {
isDragging = true;
container.style.cursor = "grabbing";
// Запоминаем начальные координаты
// (pageX/Y - абсолютные координаты курсора на странице)
// offsetLeft / offsetTop нужны, если контейнер имеет отступы
startX = e.pageX - container.offsetLeft;
startY = e.pageY - container.offsetTop;
// Запоминаем текущую прокрутку
scrollLeft = container.scrollLeft;
scrollTop = container.scrollTop;
e.preventDefault(); // Чтобы не выделялся текст
}
});
// Если мышь уходит за пределы контейнера — перестаём «тянуть»
container.addEventListener("mouseleave", () => {
isDragging = false;
container.style.cursor = "grab";
});
// Когда отпускаем кнопку мыши — перестаём «тянуть»
container.addEventListener("mouseup", () => {
isDragging = false;
container.style.cursor = "grab";
});
// При движении мыши — меняем scroll контейнера
container.addEventListener("mousemove", (e) => {
if (!isDragging) return;
e.preventDefault();
// Текущее положение мыши относительно контейнера
const x = e.pageX - container.offsetLeft;
const y = e.pageY - container.offsetTop;
// Смещение, на которое «увели» мышь
const walkX = x - startX;
const walkY = y - startY;
// Двигаем прокрутку «вверх/вниз» и «влево/вправо» (с учётом смещения)
container.scrollLeft = scrollLeft - walkX;
container.scrollTop = scrollTop - walkY;
});
}
const zoomStep = 0.1; // Шаг изменения масштаба // 0.1
// Добавляем обработчик для масштабирования при зажатом Ctrl + колесико мыши
function enableZoomControl(timelineElement) {
if (timelineElement) {
timelineElement.addEventListener("wheel", (event) => {
if (event.ctrlKey) {
event.preventDefault(); // Предотвращаем прокрутку страницы
let scale = timelineElement.dataset.scale
? parseFloat(timelineElement.dataset.scale)
: 1;
if (event.deltaY < 0) {
// Увеличение масштаба
scale += zoomStep;
} else {
// Уменьшение масштаба
scale = Math.max(scale - zoomStep, 0.1); // Минимальный масштаб: 0.1
}
timelineElement.dataset.scale = scale; // Сохраняем текущий масштаб
timelineElement.style.transform = `scale(${scale})`; // Применяем масштабирование через CSS
console.log(`Масштаб изменен: ${scale}`);
}
});
} else {
console.error('Элемент с ID "timeline" не найден.');
}
}
// Добавляем обработчик контекстного меню для нодов
function setupContextMenu(dv) {
// Если ссылки находятся в определенном контейнере
const nodes = dv.container.querySelectorAll(
".mermaid-timeline a.internal-link"
);
nodes.forEach((node) => {
node.addEventListener("contextmenu", handleContextMenu);
});
}
async function generateUniqueFileName(basePath) {
let counter = 0;
let filePath = `${basePath}.md`;
// Проверяем существование файла
while (await app.vault.adapter.exists(filePath)) {
counter++;
filePath = `${basePath} - ${counter}.md`;
}
return filePath;
}
const handleContextMenu = async (e) => {
e.preventDefault();
const node = e.currentTarget;
const nodeName = node.getAttribute("href");
// Проверяем, что нода находится внутри mermaid диаграммы
const isMermaidNode = node.closest(".mermaid") !== null;
if (!isMermaidNode) return;
const originalPage = dv
.pages("#event")
.where((p) => p.file.name === nodeName)
.first();
if (!originalPage) return;
// Создаем одноразовый обработчик события
const menuHandler = (menu, file) => {
// Проверяем, что это меню для нашего файла
if (!file || file.path !== originalPage.file.path) return;
menu.addSeparator();
menu.addItem((item) => {
item
.setTitle("Create New Event")
.onClick(() => createNewEvent(originalPage, "new"));
});
menu.addItem((item) => {
item
.setTitle("Create Previous Event")
.onClick(() => createNewEvent(originalPage, "previous"));
});
menu.addItem((item) => {
item
.setTitle("Create Next Event")
.onClick(() => createNewEvent(originalPage, "next"));
});
// Удаляем обработчик после использования
app.workspace.off("file-menu", menuHandler);
};
// Регистрируем временный обработчик
app.workspace.on("file-menu", menuHandler);
// Вызываем меню
// TODO: провоцирует ошибки, в т.ч. `app.js:1 Uncaught TypeError: e.addItem is not a function`
// и app.js:1 Uncaught TypeError: Cannot read properties of undefined (reading 'path'), но справиться с этим не удалось
app.workspace.trigger("file-menu", {
file: originalPage.file,
node: node,
x: e.pageX,
y: e.pageY,
});
};
async function createNewEvent(originalPage, type) {
// Получаем путь к папке оригинального файла
const folderPath = originalPage.file.folder;
const date = originalPage.date;
const regions = originalPage.regions;
const [year, month, day] = date.split("/");
const formattedDate = `${year}.${month.padStart(2, "0")}.${day.padStart(
2,
"0"
)}`;
// Создаем имя нового файла
const newFileName = `${formattedDate} Новое событие`;
const fullPath = folderPath ? `${folderPath}/${newFileName}` : newFileName;
try {
// Получаем содержимое шаблона
const templateContent = await app.vault.adapter.read(
"Templates/Preparation/Fields for Event.md"
);
// Генерируем уникальное имя файла
const uniqueFilePath = await generateUniqueFileName(fullPath);
// Создаем новый файл
let file = await app.vault.create(uniqueFilePath, templateContent);
await app.fileManager.processFrontMatter(file, (fm) => {
fm["date"] = date;
if (!fm["regions"]) {
fm["regions"] = [];
}
let filePath = "";
if (Array.isArray(regions)) {
regions.forEach((region) => {
if (typeof region === "object") {
filePath = region.path;
} else {
filePath = region;
}
const fileNameWithoutExt = path.basename(
filePath,
path.extname(filePath)
);
fm["regions"].push(`[[${fileNameWithoutExt}]]`);
});
} else {
if (typeof regions === "object") {
filePath = regions.path;
} else {
filePath = regions;
}
const fileNameWithoutExt = path.basename(
filePath,
path.extname(filePath)
);
fm["regions"].push(`[[${fileNameWithoutExt}]]`);
}
if (!fm["nextEvents"]) {
fm["nextEvents"] = [];
}
if (type === "previous") {
fm["nextEvents"].push(`[[${originalPage.file.name}]]`);
}
});
// Получаем только имя файла без пути и расширения
const uniqueFileName = uniqueFilePath.split("/").pop().replace(".md", "");
// Показываем уведомление об успехе
new Notice(`Создано новое событие: ${uniqueFileName}`);
// Если это следующее событие, обновляем nextEvents в текущем файле
if (type === "next") {
const currentFile = app.vault.getAbstractFileByPath(
`${originalPage.file.path}`
);
if (currentFile) {
await app.fileManager.processFrontMatter(currentFile, (fm) => {
if (!fm["nextEvents"]) {
fm["nextEvents"] = [];
}
fm["nextEvents"].push(`[[${file.basename}]]`);
});
}
}
// Открываем новый файл в новой вкладке
const leaf = app.workspace.getLeaf("tab"); // создаем новую вкладку
await leaf.openFile(file, { active: false }); // открываем файл, не переключаясь на него
// Обновлять таймлайн не надо, в случае создания нового файла самостоятельно заметка обновится
// updateTimeline();
} catch (error) {
console.error("Ошибка при создании файла:", error);
new Notice("Ошибка при создании события");
}
}
const monthsHarpotos = {
1: "Хаммер",
2: "Альтуриак",
3: "Чес",
4: "Тарсах",
5: "Миртул",
6: "Киторн",
7: "Флеймрул",
8: "Элесиас",
9: "Элейнт",
10: "Марпенот",
11: "Уктар",
12: "Найтол",
};
function showTimeline(
dv,
locations,
characters,
currentDate,
startDate,
endDate,
pcsParticipationOnly,
includeRegionsParticipants,
fullWidth = false
) {
// Выбираем страницы с полем date, попадающим в диапазон:
let pages = dv
.pages("#event")
.filter((p) => pageWithCorrectFields(dv, p, locations, characters))
.where(
(p) =>
isInRange(p, startDate, endDate) &&
(!pcsParticipationOnly || p.pcsParticipation)
);
if (pages.length == 0) {
dv.el("div", "Not found", { cls: "mermaid-timeline" });
window.lastGeneratedMermaidCode = "";
return;
}
// Группируем по "год.месяц", используя существующую функцию
// Не оптимально, надо потом упростить, попробовать уйти от displayKey ниже
let groups = pages
.groupBy((p) => {
let dateNum = customDateToNumber(p.date);
let [y, m, d] = p.date.split("/");
return {
numericKey: Math.floor(dateNum / 30), // убираем дни, оставляем только год и месяц
displayKey: `${y}.${m}`, // для отображения
};
})
.sort((g) => g.key.numericKey);
// Начинаем собирать строку для mermaid-диаграммы:
let result = "```mermaid\n";
result += `%% ниже представлены события в текущей НРИ кампании, текущая дата: ${currentDate}.\n\n`;
// для foreignObject и foreignObject div свойства в т.ч., чтобы были переносы для нодов с длинными названиями, иначе одной строкой выводит
result +=
`%%{init: {'themeCSS':
'.nodes rect { fill: none; stroke: none; }; ` +
`.cluster rect { fill: var(--background-primary-alt); stroke: var(--background-modifier-border); }` +
`.node:not(.pointNode) foreignObject { width: 100px !important; height: auto !important; overflow: visible !important; }; ` +
`.node:not(.pointNode) foreignObject div { display: block; min-width: fit-content; width: auto !important; height: auto; white-space: normal !important; padding: 4px 6px; background-color: var(--background-secondary-alt); border: 1px solid var(--background-modifier-border); }` +
`.node.pcs-participation:not(.pointNode) foreignObject div { background-color: #d7fafa; border: 1px solid var(--background-modifier-border); }` +
`.node.future-events:not(.pointNode) foreignObject div { background-color:rgb(216, 244, 177); border: 1px solid var(--background-modifier-border); }` +
`.context-menu div:hover { background-color: var(--background-modifier-hover); }` + // стили для контекстного меню
`.context-menu div { transition: background-color 0.1s ease; }` +
`'}}%%\n\n`;
result += "graph LR\n\n";
// Соберём связи и классы в массивы:
let connections = [];
let pathToNodeName = {};
let subgraphLinks = [];
let nodeClasses = []; // Для определения классов узлов
let customStyleNodeClasses = []; // Для определения классов узлов
// 1. Генерируем сабграфы
let idx = 1;
for (let group of groups) {
let monthNumber = parseInt(group.key.displayKey.split(".")[1], 10);
let monthName = monthsHarpotos[monthNumber];
result += ` subgraph Layer${idx} [${group.key.displayKey} ${monthName}]\n`;
let tNodeName = `T${idx}`;
result += ` ${tNodeName}(( ))\n`; // Добавляем узел T<i> в начало сабграфа
nodeClasses.push(` class ${tNodeName} pointNode;`);
for (let item of group.rows) {
// Уберём пробелы из названия файла, чтобы Mermaid корректно рендерил:
let nodeName = "event_" + item.file.name.replace(/[\s.,()[\]{}]+/g, "_"); // чтобы получить корректное название нода
let nodeLabel = item.file.name; // Отображаемое имя (с пробелами)
if (item.file.path) pathToNodeName[item.file.path] = nodeName;
// Добавляем ноду в сабграф с отображаемым именем
if (item.summary) result += ` %% summary: ${item.summary}\n`;
if (includeRegionsParticipants)
{
if (item.regions)
result += ` %% regions: ${item.regions}\n`;
if (item.participants)
result += ` %% participants: ${item.participants}\n`;
}
result += ` ${nodeName}[${nodeLabel}]\n`;
// Добавляем класс для ноды
nodeClasses.push(` class ${nodeName} internal-link;`);
if (customDateToNumber(item.date) > customDateToNumber(currentDate))
customStyleNodeClasses.push(` class ${nodeName} future-events;`);
else if (item.pcsParticipation)
customStyleNodeClasses.push(` class ${nodeName} pcs-participation;`);
if (item.nextEvents) {
let nextEvents = Array.isArray(item.nextEvents)
? item.nextEvents
: [item.nextEvents];
for (let nextEvent of nextEvents) {
if (nextEvent && nextEvent.path) {
connections.push([nodeName, nextEvent.path]);
}
}
}
}
result += " end\n\n";
if (idx > 1) {
// Добавляем связь между сабграфами: T<i-1> --> T<i>
subgraphLinks.push(` T${idx - 1} --> T${idx}:::customLink;`);
// A-->|Текст связи|B:::customLink;
}
idx++;
}
// 2. Добавляем блок со связями между сабграфами
if (subgraphLinks.length > 0) {
result += " %% Связи между сабграфами\n";
result += subgraphLinks.join("\n") + "\n\n";
}
// 3. Вставляем блок со связями между нодами, если они есть
if (connections.length > 0) {
result += " %% Связи\n";
for (let connection of connections) {
let fromNodeName = connection[0];
let nextEventPath = connection[1];
let nextEventNodeName = pathToNodeName[nextEventPath];
if (nextEventNodeName)
result += ` ${fromNodeName} --> ${nextEventNodeName}\n`;
}
}
result += " %% Классы узлов и связей\n";
result +=
" classDef pointNode fill:lightgray,stroke:none,opacity:1,radius:2px;\n";
result += " %% Объявляем класс для ссылок\n";
result +=
" classDef customLink stroke:lightgray,stroke-width:0px,stroke-dasharray:5 5;\n";
result += " classDef pcs-participation color:inherit;\n"; // зададим через css выше, т.к. здесь свойства для g-контейнера div'а
result += " classDef future-events color:inherit;\n"; // зададим через css выше, т.к. здесь свойства для g-контейнера div'а
// 4. Вставляем блок для классов узлов
if (nodeClasses.length > 0) {
result += " %% Классы узлов\n";
result += nodeClasses.join("\n") + "\n";
result += customStyleNodeClasses.join("\n") + "\n";
}
// Закрываем кодовый блок с мермейдом
result += "```";
// Создаём div с конкретным классом
let mermaidTimelineDiv = dv.el("div", result, { cls: "mermaid-timeline" });
// Сохраняем сгенерированный код
window.lastGeneratedMermaidCode = result;
// Активируем drag-перемещение
enableDiagramDragging(dv);
// Вызываем функцию после рендеринга диаграммы
setTimeout(() => {
setupContextMenu(dv);
enableZoomControl(mermaidTimelineDiv.querySelector(".mermaid"));
}, 1000); // Даем время на рендеринг Mermaid, передаём dv через лямбду
}
async function run_show_timeline(...args) {
if (!args[0] || !args[0].arg5) return; // не имеет свойства length
let dv = args[0].arg1;
let locations = args[0].arg2; // Предполагается, что это массив целевых локаций
let characters = args[0].arg3;
let defaultCurrentDate = args[0].arg4;
let startDate = args[0].arg5;
let endDate = args[0].arg6;
let pcsParticipationOnly = args[0].arg7;
let includeRegionsParticipants = args[0].arg8;
showTimeline(
dv,
locations,
characters,
defaultCurrentDate,
startDate,
endDate,
pcsParticipationOnly,
includeRegionsParticipants
);
}
await run_show_timeline(input);
dv.timeline = {
showTimeline,
};

15
utils.js Normal file
View File

@ -0,0 +1,15 @@
// Асинхронная функция для отображения содержимого заметки
async function displayNoteContent(dv, noteName) {
let note = dv.page(noteName);
if (note) {
// Используем dv.io.load для чтения содержимого файла
let content = await dv.io.load(note.file.path);
dv.paragraph(content);
} else {
dv.paragraph(`Заметка "${noteName}" не найдена.`);
}
}
dv.utils = {
displayNoteContent
};

634
weather/weather.py Normal file
View File

@ -0,0 +1,634 @@
"""
weather.py головной модуль генератора погоды ACKS II.
Использует классификацию климата Кёппена (Köppen) + сезон для генерации
случайной погоды на основе бросков 2d6 с модификаторами.
Экспортирует функцию generate_weather(constraints), которую вызывает тул с фронта.
constraints список объектов вида:
{
"koppen": "Cfb", # Köppen-код климата (обязательно)
"season": "Winter", # Winter / Spring / Summer / Fall (обязательно)
"count": 3, # сколько дней сгенерировать (default 1)
}
Возвращает строку в формате Markdown.
"""
import random
from typing import Any
from typing_extensions import TypedDict
# ──────────────────────────────────────────────────────────────────────────────
# Единая температурная таблица [-7 … 19]
# Устраняет скачок между Temp_cold и Temp_hot:
# оба конца шкалы теперь в одном непрерывном словаре.
# ──────────────────────────────────────────────────────────────────────────────
TEMP_TABLE: dict[int, str] = {
-7: "Frigid (-75 °F или ниже)",
-6: "Frigid (-65 до -74 °F)",
-5: "Frigid (-55 до -64 °F)",
-4: "Frigid (-50 до -54 °F)",
-3: "Frigid (-40 до -49 °F)",
-2: "Frigid (-25 до -39 °F)",
-1: "Frigid (-12 до -24 °F)",
0: "Frigid (-6 до -11 °F)",
1: "Frigid (-5 до 0 °F)",
2: "Cold (615 °F)",
3: "Cold (1625 °F)",
4: "Cold (2632 °F)",
5: "Very Chilly (3336 °F)",
6: "Very Chilly (3740 °F)",
7: "Chilly (4150 °F)",
8: "Chilly (5155 °F)",
9: "Brisk (5660 °F)",
10: "Brisk (6165 °F)",
11: "Balmy (6668 °F)",
12: "Balmy (6972 °F)",
13: "Warm (7376 °F)",
14: "Warm (7780 °F)",
15: "Hot (8186 °F)",
16: "Hot (8792 °F)",
17: "Sweltering (93100 °F)",
18: "Sweltering (101110 °F)",
19: "Sweltering (111 °F или выше)",
}
# Порог температурного индекса, ниже которого осадки — снег/метель,
# а не дождь. Индекс 4 = Cold (2632 °F) — около точки замерзания.
SNOW_THRESHOLD = 4
# ──────────────────────────────────────────────────────────────────────────────
# Таблица осадков [-7 … 19]
# ──────────────────────────────────────────────────────────────────────────────
PRECIPITATION_TABLE: dict[int, str] = {
-7: "Sunbaked",
-6: "Sunbaked",
-5: "Sunbaked",
-4: "Sunbaked",
-3: "Sunbaked",
-2: "Sunbaked",
-1: "Clear",
0: "Clear",
1: "Clear",
2: "Clear",
3: "Clear",
4: "Partly Cloudy",
5: "Mostly Cloudy",
6: "Overcast",
7: "Drizzly",
8: "Drizzly",
9: "Drizzly",
10: "Rainy",
11: "Rainy",
12: "Rainy",
13: "Rainy",
14: "Rainy",
15: "Rainy",
16: "Rainy",
17: "Rainy",
18: "Rainy",
19: "Rainy",
}
# Соответствие «тёплых» осадков → «зимним» при t_index < SNOW_THRESHOLD.
# Drizzly → Flurries (снежная крупа), Rainy → Snowy (снег/метель).
PRECIP_COLD_REMAP: dict[str, str] = {
"Drizzly": "Flurries (снежная крупа)",
"Rainy": "Snowy / Blizzard (снег / метель)",
}
# ──────────────────────────────────────────────────────────────────────────────
# Таблица ветра [-7 … 19]
# ──────────────────────────────────────────────────────────────────────────────
WINDS_TABLE: dict[int, str] = {
-7: "Still",
-6: "Still",
-5: "Still",
-4: "Still",
-3: "Still",
-2: "Still",
-1: "Still",
0: "Still",
1: "Still",
2: "Still",
3: "Still",
4: "Still",
5: "Gentle",
6: "Gentle",
7: "Moderate",
8: "Moderate",
9: "Moderate",
10: "Strong",
11: "Strong",
12: "Windy",
13: "Windy",
14: "Stormy",
15: "Stormy",
16: "Stormy",
17: "Stormy",
18: "Stormy",
19: "Stormy",
}
# ──────────────────────────────────────────────────────────────────────────────
# Климатические модификаторы (Köppen × Season)
# td — смещение дневного броска температуры
# tn — дополнительное смещение ночного броска (поверх td)
# p — смещение броска осадков
# w — смещение броска ветра
#
# Исправлено: p=0 → p=0 (Cwc Spring)
# ──────────────────────────────────────────────────────────────────────────────
CLIMATE_MODS: dict[str, dict[str, dict[str, int]]] = {
"Af": {
"Winter": {"td": +3, "tn": 0, "p": -3, "w": +2},
"Spring": {"td": +2, "tn": 0, "p": -2, "w": 0},
"Summer": {"td": +2, "tn": 0, "p": -2, "w": 0},
"Fall": {"td": +1, "tn": 0, "p": -1, "w": 0},
},
"Am": {
"Winter": {"td": +2, "tn": 0, "p": -1, "w": +2},
"Spring": {"td": +7, "tn": 0, "p": -1, "w": 0},
"Summer": {"td": +6, "tn": 0, "p": +4, "w": 0},
"Fall": {"td": +4, "tn": 0, "p": -1, "w": 0},
},
"Aw": {
"Winter": {"td": +5, "tn": 0, "p": -5, "w": +2},
"Spring": {"td": +4, "tn": 0, "p": -4, "w": 0},
"Summer": {"td": +3, "tn": 0, "p": -1, "w": 0},
"Fall": {"td": +3, "tn": 0, "p": -4, "w": 0},
},
"As": {
"Winter": {"td": +4, "tn": 0, "p": -1, "w": +2},
"Spring": {"td": +4, "tn": 0, "p": -2, "w": 0},
"Summer": {"td": +4, "tn": 0, "p": -5, "w": 0},
"Fall": {"td": +4, "tn": 0, "p": -3, "w": 0},
},
"BWh": {
"Winter": {"td": +1, "tn": 0, "p": -5, "w": +2},
"Spring": {"td": +6, "tn": 0, "p": -5, "w": 0},
"Summer": {"td": +7, "tn": +2, "p": -5, "w": 0},
"Fall": {"td": +4, "tn": 0, "p": 0, "w": 0},
},
"BWk": {
"Winter": {"td": 0, "tn": -4, "p": -4, "w": +2},
"Spring": {"td": 0, "tn": 0, "p": -5, "w": 0},
"Summer": {"td": +2, "tn": 0, "p": -4, "w": 0},
"Fall": {"td": 0, "tn": -1, "p": -5, "w": 0},
},
"BSh": {
"Winter": {"td": +6, "tn": 0, "p": -5, "w": +2},
"Spring": {"td": +7, "tn": +3, "p": -3, "w": 0},
"Summer": {"td": +6, "tn": +2, "p": -2, "w": 0},
"Fall": {"td": +6, "tn": +2, "p": -5, "w": 0},
},
"BSk": {
"Winter": {"td": 0, "tn": -1, "p": -4, "w": +2},
"Spring": {"td": +1, "tn": 0, "p": -4, "w": 0},
"Summer": {"td": +4, "tn": 0, "p": -5, "w": 0},
"Fall": {"td": 0, "tn": 0, "p": -4, "w": 0},
},
"Csa": {
"Winter": {"td": 0, "tn": 0, "p": -4, "w": +2},
"Spring": {"td": +1, "tn": 0, "p": -4, "w": 0},
"Summer": {"td": +3, "tn": 0, "p": -4, "w": 0},
"Fall": {"td": +1, "tn": 0, "p": -3, "w": 0},
},
"Csb": {
"Winter": {"td": 0, "tn": 0, "p": -2, "w": +2},
"Spring": {"td": 0, "tn": 0, "p": -3, "w": 0},
"Summer": {"td": +2, "tn": 0, "p": -4, "w": 0},
"Fall": {"td": 0, "tn": 0, "p": -1, "w": 0},
},
"Csc": {
"Winter": {"td": 0, "tn": 0, "p": +2, "w": +2},
"Spring": {"td": 0, "tn": 0, "p": -3, "w": 0},
"Summer": {"td": 0, "tn": 0, "p": -3, "w": 0},
"Fall": {"td": 0, "tn": 0, "p": -2, "w": 0},
},
"Cwa": {
"Winter": {"td": +1, "tn": 0, "p": -3, "w": +2},
"Spring": {"td": +3, "tn": 0, "p": -2, "w": 0},
"Summer": {"td": +4, "tn": +2, "p": -1, "w": +2},
"Fall": {"td": +2, "tn": 0, "p": -3, "w": 0},
},
"Cwb": {
"Winter": {"td": +1, "tn": 0, "p": -3, "w": +2},
"Spring": {"td": +1, "tn": 0, "p": -2, "w": 0},
"Summer": {"td": +1, "tn": 0, "p": -1, "w": +2},
"Fall": {"td": +1, "tn": 0, "p": -5, "w": 0},
},
"Cwc": {
"Winter": {"td": 0, "tn": 0, "p": -3, "w": +2},
"Spring": {"td": 0, "tn": 0, "p": 0, "w": 0}, # было p=-0
"Summer": {"td": 0, "tn": 0, "p": +2, "w": +2},
"Fall": {"td": 0, "tn": 0, "p": -2, "w": 0},
},
"Cfa": {
"Winter": {"td": 0, "tn": 0, "p": -3, "w": +2},
"Spring": {"td": +2, "tn": 0, "p": -2, "w": 0},
"Summer": {"td": +4, "tn": +1, "p": -1, "w": 0},
"Fall": {"td": +1, "tn": 0, "p": -4, "w": 0},
},
"Cfb": {
"Winter": {"td": 0, "tn": 0, "p": -1, "w": +2},
"Spring": {"td": +1, "tn": 0, "p": -1, "w": 0},
"Summer": {"td": +3, "tn": +1, "p": -1, "w": 0},
"Fall": {"td": 0, "tn": 0, "p": -2, "w": 0},
},
"Cfc": {
"Winter": {"td": 0, "tn": -1, "p": -3, "w": +2},
"Spring": {"td": 0, "tn": 0, "p": -3, "w": 0},
"Summer": {"td": 0, "tn": 0, "p": -3, "w": 0},
"Fall": {"td": 0, "tn": 0, "p": -3, "w": 0},
},
"Dsa": {
"Winter": {"td": 0, "tn": -2, "p": -3, "w": +2},
"Spring": {"td": +3, "tn": +1, "p": -4, "w": 0},
"Summer": {"td": +5, "tn": +3, "p": -5, "w": 0},
"Fall": {"td": +2, "tn": +1, "p": -4, "w": 0},
},
"Dsb": {
"Winter": {"td": -2, "tn": -5, "p": -4, "w": +2},
"Spring": {"td": +1, "tn": -2, "p": -2, "w": 0},
"Summer": {"td": +3, "tn": +1, "p": -3, "w": 0},
"Fall": {"td": 0, "tn": -1, "p": -3, "w": 0},
},
"Dsc": {
"Winter": {"td": -2, "tn": -3, "p": -2, "w": +2},
"Spring": {"td": +1, "tn": -1, "p": -3, "w": 0},
"Summer": {"td": +2, "tn": -1, "p": -2, "w": 0},
"Fall": {"td": -1, "tn": -1, "p": -1, "w": 0},
},
"Dwa": {
"Winter": {"td": 0, "tn": -3, "p": -5, "w": +2},
"Spring": {"td": +1, "tn": 0, "p": -4, "w": 0},
"Summer": {"td": +3, "tn": 0, "p": -2, "w": 0},
"Fall": {"td": 0, "tn": 0, "p": -1, "w": 0},
},
"Dwb": {
"Winter": {"td": -1, "tn": -3, "p": -4, "w": +2},
"Spring": {"td": +2, "tn": 0, "p": -3, "w": 0},
"Summer": {"td": +3, "tn": +1, "p": -2, "w": 0},
"Fall": {"td": 0, "tn": 0, "p": -3, "w": 0},
},
"Dwc": {
"Winter": {"td": -1, "tn": -5, "p": -4, "w": +2},
"Spring": {"td": 0, "tn": -3, "p": -4, "w": 0},
"Summer": {"td": +1, "tn": -1, "p": -2, "w": 0},
"Fall": {"td": 0, "tn": -2, "p": -3, "w": 0},
},
"Dwd": {
"Winter": {"td": -9, "tn": -11, "p": -3, "w": +2},
"Spring": {"td": 0, "tn": -4, "p": -4, "w": 0},
"Summer": {"td": +2, "tn": -2, "p": -3, "w": +2},
"Fall": {"td": -4, "tn": -7, "p": -5, "w": 0},
},
"Dfa": {
"Winter": {"td": -2, "tn": -4, "p": -3, "w": +2},
"Spring": {"td": +3, "tn": -1, "p": -2, "w": 0},
"Summer": {"td": +4, "tn": +2, "p": -4, "w": +2},
"Fall": {"td": +2, "tn": -2, "p": -3, "w": 0},
},
"Dfb": {
"Winter": {"td": -3, "tn": -4, "p": -3, "w": +2},
"Spring": {"td": +2, "tn": -2, "p": -2, "w": 0},
"Summer": {"td": +3, "tn": +1, "p": -3, "w": +2},
"Fall": {"td": 0, "tn": -2, "p": -3, "w": 0},
},
"Dfc": {
"Winter": {"td": -3, "tn": -5, "p": -4, "w": +2},
"Spring": {"td": +1, "tn": -2, "p": -4, "w": 0},
"Summer": {"td": +2, "tn": 0, "p": -3, "w": 0},
"Fall": {"td": -1, "tn": -2, "p": -3, "w": 0},
},
"Dfd": {
"Winter": {"td": -9, "tn": -11, "p": -4, "w": +2},
"Spring": {"td": 0, "tn": -4, "p": -4, "w": 0},
"Summer": {"td": +2, "tn": -2, "p": -4, "w": 0},
"Fall": {"td": -5, "tn": -6, "p": -5, "w": 0},
},
"ET": {
"Winter": {"td": -8, "tn": -11, "p": -2, "w": +2},
"Spring": {"td": 0, "tn": -1, "p": -3, "w": 0},
"Summer": {"td": 0, "tn": 0, "p": -2, "w": 0},
"Fall": {"td": -1, "tn": -2, "p": -1, "w": 0},
},
"EF": {
# Примечание: EF (Антарктида/ледники) даёт td=-11 для всех сезонов.
# Из-за клэмпинга [-7, 19] большинство бросков упрётся в -7 («Frigid»).
# Это архитектурное ограничение системы 2d6: вариативность намеренно
# мала — все дни там одинаково экстремальны.
"Winter": {"td": -11, "tn": -11, "p": -5, "w": -2},
"Spring": {"td": -11, "tn": -11, "p": -5, "w": 0},
"Summer": {"td": -11, "tn": -11, "p": -5, "w": 0},
"Fall": {"td": -11, "tn": -11, "p": -5, "w": 0},
},
}
VALID_SEASONS = ("Winter", "Spring", "Summer", "Fall")
# ──────────────────────────────────────────────────────────────────────────────
# Вспомогательные функции
# ──────────────────────────────────────────────────────────────────────────────
def _roll_2d6() -> int:
"""Бросок 2d6 → [2, 12]."""
return random.randint(1, 6) + random.randint(1, 6)
def _clamp(value: int, lo: int = -7, hi: int = 19) -> int:
return max(lo, min(hi, value))
def _lookup_temp(index: int) -> str:
"""Возвращает описание температуры по индексу (с клэмпингом)."""
return TEMP_TABLE[_clamp(index)]
def _lookup_precip(index: int, t_index: int) -> str:
"""
Возвращает описание осадков с учётом температуры.
Если t_index < SNOW_THRESHOLD и осадки «тёплые» (Drizzly/Rainy),
автоматически заменяет их на снежный эквивалент.
"""
raw = PRECIPITATION_TABLE[_clamp(index)]
if t_index < SNOW_THRESHOLD and raw in PRECIP_COLD_REMAP:
return PRECIP_COLD_REMAP[raw]
return raw
def _lookup_wind(index: int) -> str:
return WINDS_TABLE[_clamp(index)]
def _f_to_c(fahrenheit: float) -> float:
"""Конвертация °F → °C."""
return (fahrenheit - 32) * 5 / 9
# Регулярное выражение для замены диапазонов °F на °C в строках таблицы.
import re
def _convert_temp_str_to_celsius(temp_str: str) -> str:
"""
Принимает строку вида 'Balmy (6972 °F)' или 'Frigid (-75 °F или ниже)'
и возвращает строку с диапазоном в °C.
Обрабатывает форматы:
(X °F или ниже)
(X до Y °F)
(XY °F)
(X °F)
"""
# «-75 °F или ниже»
m = re.search(r'\((-?\d+)\s*°F или ниже\)', temp_str)
if m:
f = int(m.group(1))
c = round(_f_to_c(f))
return re.sub(r'\((-?\d+)\s*°F или ниже\)', f'({c} °C или ниже)', temp_str)
# «111 °F или выше»
m = re.search(r'\((-?\d+)\s*°F или выше\)', temp_str)
if m:
f = int(m.group(1))
c = round(_f_to_c(f))
return re.sub(r'\((-?\d+)\s*°F или выше\)', f'({c} °C или выше)', temp_str)
# «-65 до -74 °F»
m = re.search(r'\((-?\d+)\s*до\s*(-?\d+)\s*°F\)', temp_str)
if m:
c1 = round(_f_to_c(int(m.group(1))))
c2 = round(_f_to_c(int(m.group(2))))
return re.sub(r'\((-?\d+)\s*до\s*(-?\d+)\s*°F\)', f'({c1} до {c2} °C)', temp_str)
# «6972 °F»
m = re.search(r'\((-?\d+)(-?\d+)\s*°F\)', temp_str)
if m:
c1 = round(_f_to_c(int(m.group(1))))
c2 = round(_f_to_c(int(m.group(2))))
return re.sub(r'\((-?\d+)(-?\d+)\s*°F\)', f'({c1}{c2} °C)', temp_str)
# одиночное значение «X °F»
m = re.search(r'\((-?\d+)\s*°F\)', temp_str)
if m:
c = round(_f_to_c(int(m.group(1))))
return re.sub(r'\((-?\d+)\s*°F\)', f'({c} °C)', temp_str)
return temp_str
# ──────────────────────────────────────────────────────────────────────────────
# Генерация одного дня
# ──────────────────────────────────────────────────────────────────────────────
def _generate_single_day(koppen: str, season: str, use_celsius: bool = False) -> str:
"""
Генерирует погоду на один день и возвращает Markdown-строку.
Механика:
- Дневная температура: 2d6 + td клэмп таблица
- Ночная температура: (2d6 + td) + tn клэмп таблица
(tn дополнительная дельта ночи относительно дня)
- Осадки: 2d6 + p клэмп таблица + проверка снега
- Ветер: 2d6 + w клэмп таблица
"""
mods = CLIMATE_MODS[koppen][season]
td = mods["td"]
tn = mods["tn"] # смещение ночи поверх дневного броска
p = mods["p"]
w = mods["w"]
# Бросок температуры — один базовый бросок для дня
base_t_roll = _roll_2d6()
day_t_index = _clamp(base_t_roll + td)
# Ночь: тот же базовый бросок + tn (ночная дельта стабильна для климата,
# но сам бросок дня добавляет случайность)
night_t_index = _clamp(base_t_roll + td + tn)
p_roll = _clamp(_roll_2d6() + p)
w_roll = _clamp(_roll_2d6() + w)
day_temp = _lookup_temp(day_t_index)
night_temp = _lookup_temp(night_t_index)
# Осадки проверяем по ДНЕВНОЙ температуре
# (если днём уже ниже нуля — всё идёт снегом)
precip = _lookup_precip(p_roll, day_t_index)
wind = _lookup_wind(w_roll)
# Отладочные индексы (удобно при тестировании)
debug = (
f"*(rolls: base_t={base_t_roll}, "
f"day_idx={day_t_index}, night_idx={night_t_index}, "
f"p_idx={p_roll}, w_idx={w_roll})*"
)
if use_celsius:
day_temp = _convert_temp_str_to_celsius(day_temp)
night_temp = _convert_temp_str_to_celsius(night_temp)
return (
f"- **День:** {day_temp} | "
f"**Ночь:** {night_temp} | "
f"**Осадки:** {precip} | "
f"**Ветер:** {wind} "
# f"\n {debug}"
)
# ──────────────────────────────────────────────────────────────────────────────
# TypedDict для аннотации параметров тула
# ──────────────────────────────────────────────────────────────────────────────
class WeatherConstraint(TypedDict, total=False):
"""
Параметры генерации погоды для одной локации/периода.
Поля:
koppen: Köppen-код климата. Допустимые значения:
Af, Am, Aw, As,
BWh, BWk, BSh, BSk,
Csa, Csb, Csc, Cwa, Cwb, Cwc, Cfa, Cfb, Cfc,
Dsa, Dsb, Dsc, Dwa, Dwb, Dwc, Dwd, Dfa, Dfb, Dfc, Dfd,
ET, EF.
season: Сезон: Winter, Spring, Summer, Fall.
count: Количество дней для генерации. По умолчанию 1.
use_celsius: default False если True, выводить °C вместо °F
"""
koppen: str # обязательно
season: str # обязательно
count: int # default 1
use_celsius: bool # default False — если True, выводить °C вместо °F
# ──────────────────────────────────────────────────────────────────────────────
# Публичный метод тула
# ──────────────────────────────────────────────────────────────────────────────
def generate_weather(
debug_description: str,
constraints: list[WeatherConstraint],
) -> str:
"""
Основной метод, который вызывает тул с фронта.
Принимает список объектов WeatherConstraint. Для каждого объекта:
- валидирует Köppen-код и сезон;
- генерирует `count` дней погоды.
Возвращает Markdown-строку со всеми результатами.
Пример вызова:
generate_weather("тест", [
{"koppen": "Cfb", "season": "Winter", "count": 3},
{"koppen": "BWh", "season": "Summer", "count": 1},
])
"""
if not constraints:
return "Не передано ни одного ограничения."
sections: list[str] = []
for idx, constraint in enumerate(constraints, start=1):
koppen = constraint.get("koppen", "").strip()
season = constraint.get("season", "").strip()
count = int(constraint.get("count", 1))
use_celsius = bool(constraint.get("use_celsius", False))
# ── Валидация ────────────────────────────────────────────────────────
errors: list[str] = []
if not koppen:
errors.append("не указан `koppen`")
elif koppen not in CLIMATE_MODS:
valid = ", ".join(sorted(CLIMATE_MODS.keys()))
errors.append(f"неизвестный Köppen-код `{koppen}`. Допустимые: {valid}")
if not season:
errors.append("не указан `season`")
elif season not in VALID_SEASONS:
errors.append(
f"неизвестный сезон `{season}`. "
f"Допустимые: {', '.join(VALID_SEASONS)}"
)
if errors:
sections.append(
f"### Набор {idx} — ошибка\n" + "\n".join(f"- {e}" for e in errors)
)
continue
count = max(1, min(count, 30)) # не более 30 дней за раз
# ── Генерация ────────────────────────────────────────────────────────
mods = CLIMATE_MODS[koppen][season]
header = (
f"### Набор {idx}: {koppen} / {season} ({count} дн.)\n"
# f"*Модификаторы: td={mods['td']:+d}, tn={mods['tn']:+d}, "
# f"p={mods['p']:+d}, w={mods['w']:+d}*\n"
)
lines: list[str] = [header]
for day_num in range(1, count + 1):
day_line = _generate_single_day(koppen, season, use_celsius)
lines.append(f"**День {day_num}:** \n{day_line}")
sections.append("\n\n".join(lines))
return "\n\n---\n\n".join(sections)
# ──────────────────────────────────────────────────────────────────────────────
# Быстрый тест при запуске напрямую
# ──────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
result = generate_weather(
"тест",
[
{
"koppen": "Cfb",
"season": "Winter",
"count": 3
},
{
"koppen": "BWh",
"season": "Summer",
"count": 2
},
{
"koppen": "EF",
"season": "Winter",
"count": 2
},
{
"koppen": "Dwd",
"season": "Winter",
"count": 2
},
{
"koppen": "Af",
"season": "Summer",
"count": 1
},
# Проверка ошибки валидации
{
"koppen": "XXX",
"season": "Winter",
"count": 1
},
])
print(result)