From e90a2e2e4333529b579ef93053c00d082e745426 Mon Sep 17 00:00:00 2001 From: dimitrievgs Date: Wed, 17 Jun 2026 22:32:48 +0300 Subject: [PATCH] Initial commit --- .gitignore | 3 + dice_roller/dice_roller.py | 166 ++ game_utils.py | 33 + .../Encounter Classification.md | 69 + .../encounter_generator (old).py | 203 +++ generate_encounter/generate_encounter.py | 465 +++++ generate_encounter/get_creature.py | 97 + generate_encounter/get_npc_races.py | 290 +++ generate_encounter/hooks_data.py | 323 ++++ generate_encounter/monsters_data — копия.py | 174 ++ generate_encounter/monsters_data.py | 1511 ++++++++++++++++ .../surprise_rolls_and_reaction.md | 92 + generate_npc/abilities.js | 96 + generate_npc/abilities.py | 61 + generate_npc/ages.js | 112 ++ generate_npc/ages.py | 55 + generate_npc/backgrounds.js | 1575 ++++++++++++++++ generate_npc/backgrounds.py | 390 ++++ generate_npc/bio_tables.py | 376 ++++ generate_npc/classes.js | 90 + generate_npc/classes.py | 75 + generate_npc/generate_npc.js | 248 +++ generate_npc/generate_npc.py | 435 +++++ generate_npc/languages.js | 474 +++++ generate_npc/languages.py | 74 + generate_npc/norminv.js | 13 + generate_npc/norminv.py | 14 + generate_npc/psychoTypes.js | 244 +++ generate_npc/psycho_types.py | 62 + generate_npc/races.js | 781 ++++++++ generate_npc/races.py | 492 +++++ generate_npc/skills.js | 105 ++ generate_npc/skills.py | 55 + generate_trap/generate_trap (old).py | 583 ++++++ generate_trap/generate_trap.py | 622 +++++++ generate_trap/traps_tables.md | 139 ++ generate_treasure/generate_treasure.py | 1582 +++++++++++++++++ locationTable.js | 65 + locationTypesList.js | 133 ++ locations.js | 80 + magicItemShops.js | 34 + md-files-folder-wise-list.bat | 33 + mercenariesHirePlaces.js | 32 + merge_markdown.bat | 4 + npcTable.js | 135 ++ replace_field_name.py | 27 + requirements.txt | 2 + showLocationTabs.js | 56 + spellScrollsShops.js | 34 + sync_fields.py | 175 ++ timeline-widget.js | 319 ++++ timeline.js | 577 ++++++ utils.js | 15 + weather/weather.py | 634 +++++++ 54 files changed, 14534 insertions(+) create mode 100644 .gitignore create mode 100644 dice_roller/dice_roller.py create mode 100644 game_utils.py create mode 100644 generate_encounter/Encounter Classification.md create mode 100644 generate_encounter/encounter_generator (old).py create mode 100644 generate_encounter/generate_encounter.py create mode 100644 generate_encounter/get_creature.py create mode 100644 generate_encounter/get_npc_races.py create mode 100644 generate_encounter/hooks_data.py create mode 100644 generate_encounter/monsters_data — копия.py create mode 100644 generate_encounter/monsters_data.py create mode 100644 generate_encounter/surprise_rolls_and_reaction.md create mode 100644 generate_npc/abilities.js create mode 100644 generate_npc/abilities.py create mode 100644 generate_npc/ages.js create mode 100644 generate_npc/ages.py create mode 100644 generate_npc/backgrounds.js create mode 100644 generate_npc/backgrounds.py create mode 100644 generate_npc/bio_tables.py create mode 100644 generate_npc/classes.js create mode 100644 generate_npc/classes.py create mode 100644 generate_npc/generate_npc.js create mode 100644 generate_npc/generate_npc.py create mode 100644 generate_npc/languages.js create mode 100644 generate_npc/languages.py create mode 100644 generate_npc/norminv.js create mode 100644 generate_npc/norminv.py create mode 100644 generate_npc/psychoTypes.js create mode 100644 generate_npc/psycho_types.py create mode 100644 generate_npc/races.js create mode 100644 generate_npc/races.py create mode 100644 generate_npc/skills.js create mode 100644 generate_npc/skills.py create mode 100644 generate_trap/generate_trap (old).py create mode 100644 generate_trap/generate_trap.py create mode 100644 generate_trap/traps_tables.md create mode 100644 generate_treasure/generate_treasure.py create mode 100644 locationTable.js create mode 100644 locationTypesList.js create mode 100644 locations.js create mode 100644 magicItemShops.js create mode 100644 md-files-folder-wise-list.bat create mode 100644 mercenariesHirePlaces.js create mode 100644 merge_markdown.bat create mode 100644 npcTable.js create mode 100644 replace_field_name.py create mode 100644 requirements.txt create mode 100644 showLocationTabs.js create mode 100644 spellScrollsShops.js create mode 100644 sync_fields.py create mode 100644 timeline-widget.js create mode 100644 timeline.js create mode 100644 utils.js create mode 100644 weather/weather.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9fac9d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__ + +*.pyc \ No newline at end of file diff --git a/dice_roller/dice_roller.py b/dice_roller/dice_roller.py new file mode 100644 index 0000000..81fd27c --- /dev/null +++ b/dice_roller/dice_roller.py @@ -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) \ No newline at end of file diff --git a/game_utils.py b/game_utils.py new file mode 100644 index 0000000..f3051ef --- /dev/null +++ b/game_utils.py @@ -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}" diff --git a/generate_encounter/Encounter Classification.md b/generate_encounter/Encounter Classification.md new file mode 100644 index 0000000..c6217d9 --- /dev/null +++ b/generate_encounter/Encounter Classification.md @@ -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). Скрипт просто дает «сцену», а кубики — «драму». \ No newline at end of file diff --git a/generate_encounter/encounter_generator (old).py b/generate_encounter/encounter_generator (old).py new file mode 100644 index 0000000..9da6e7f --- /dev/null +++ b/generate_encounter/encounter_generator (old).py @@ -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)) diff --git a/generate_encounter/generate_encounter.py b/generate_encounter/generate_encounter.py new file mode 100644 index 0000000..da70b54 --- /dev/null +++ b/generate_encounter/generate_encounter.py @@ -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: Средний уровень группы (1–20). + По умолчанию: 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)) diff --git a/generate_encounter/get_creature.py b/generate_encounter/get_creature.py new file mode 100644 index 0000000..23173c0 --- /dev/null +++ b/generate_encounter/get_creature.py @@ -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: Средний уровень группы (1–20). + + 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)}") \ No newline at end of file diff --git a/generate_encounter/get_npc_races.py b/generate_encounter/get_npc_races.py new file mode 100644 index 0000000..96de1ef --- /dev/null +++ b/generate_encounter/get_npc_races.py @@ -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(), []) \ No newline at end of file diff --git a/generate_encounter/hooks_data.py b/generate_encounter/hooks_data.py new file mode 100644 index 0000000..7f07b3a --- /dev/null +++ b/generate_encounter/hooks_data.py @@ -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, + }, + }, +} \ No newline at end of file diff --git a/generate_encounter/monsters_data — копия.py b/generate_encounter/monsters_data — копия.py new file mode 100644 index 0000000..7552eb1 --- /dev/null +++ b/generate_encounter/monsters_data — копия.py @@ -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")] + }, + +} diff --git a/generate_encounter/monsters_data.py b/generate_encounter/monsters_data.py new file mode 100644 index 0000000..0771ee0 --- /dev/null +++ b/generate_encounter/monsters_data.py @@ -0,0 +1,1511 @@ +# Таблицы с монстрами и животными + +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")] + }, + "desert": { + "1-4": + [(1, 1, "3d8 scorpions"), (2, 2, "2d4 vultures"), + (3, 3, "1 abandoned mule"), (4, 4, "2d6 commoners with 2d4 camels"), + (5, 5, "1d6 flying snakes"), (6, 6, "2d6 hyenas or 2d6 jackals"), + (7, 7, "1d6 guards escorting a noble"), (8, 8, "1d6 cats"), + (9, 9, "1 pseudodragon"), (10, 10, "1d4 poisonous snakes"), + (11, 13, "2d4 stirges"), (14, 15, "1d6 + 2 giant wolf spiders"), + (16, 17, "1 scout"), (18, 20, "2d4 giant poisonous snakes"), + (21, 25, "Single-file tracks marching deeper"), + (26, 27, "4d4 kobolds"), (28, 29, "1 jackalwere"), + (30, 31, "3d6 tribal warriors"), (32, 33, "1d6 giant lizards"), + (34, 35, "1 swarm of insects"), + (36, 40, "An oasis with palm trees and remnants of a camp"), + (41, 44, "3d6 bandits"), (45, 46, "1d4 constrictor snakes"), + (47, 48, "2d4 winged kobolds"), (49, 50, "1 dust mephit"), + (51, 52, "1d3 + 1 giant toads"), (53, 54, "1d4 giant spiders"), + (55, 55, "1 druid"), (56, 57, "2d4 hobgoblins"), (58, 58, "1 wight"), + (59, 60, "1 ogre"), (61, 65, "A brass lamp"), + (66, 67, "1d4 giant vultures"), (68, 68, "1 phase spider"), + (69, 69, "1 giant constrictor snake"), + (70, 71, "1 gnoll pack lord with 1d3 giant hyenas"), + (72, 72, "1d6 + 2 gnolls"), (73, 74, "1 mummy"), + (75, 75, "1d3 half-ogres"), (76, 80, "A pile of humanoid bones"), + (81, 82, "1 lamia"), + (83, 83, "1 hobgoblin captain with 2d6 hobgoblins"), + (84, 84, "2d4 death dogs"), (85, 86, "1d4 giant scorpions"), + (87, 87, "1 yuan-ti malison with 1d4 + 1 yuan-ti purebloods"), + (88, 89, "1 bandit captain with 1 druid and 3d6 bandits"), + (90, 90, "2d4 thri-kreen"), (91, 91, "1 air elemental"), + (92, 92, "1d3 couatls"), (93, 93, "1 fire elemental"), + (94, 94, "1d4 gnoll fangs of Yeenoghu"), (95, 95, "1 revenant"), + (96, 96, "1d4 weretigers"), (97, 97, "1 cyclops"), + (98, 98, "1 young brass dragon"), (99, 99, "1 medusa"), + (100, 100, "1 yuan-ti abomination")], + "5-10": [ + (1, 1, "1 scout"), (2, 2, "2d4 jackalweres"), + (3, 3, "2d6 hobgoblins"), (4, 4, "1d4 + 3 dust mephits"), + (5, 5, "1d6 swarm of insects"), + (6, 6, "1 giant constrictor snake"), (7, 8, "1 lion"), + (9, 10, "2d4 gnolls"), (11, 12, "2d6 giant toads"), + (13, 17, "1 mummy"), (18, 20, "1d8 + 1 giant vultures"), + (21, 25, "A stone obelisk buried in sand"), + (26, 28, "1 ogre with 1d3 half-ogres"), + (29, 35, "1d10 giant hyenas"), (36, 40, "1d6 + 1 empty tents"), + (41, 43, "1d6 + 2 thri-kreen"), (44, 46, "2d4 yuan-ti purebloods"), + (47, 50, "1d6 + 3 death dogs"), (51, 52, "1d4 giant scorpions"), + (53, 53, "1 fire elemental"), + (54, 55, "1 hobgoblin captain with 3d4 hobgoblins"), + (56, 56, "1d6 + 2 ogres"), (57, 58, "1d4 lamias"), + (59, 60, "1 air elemental"), + (61, 65, "A meteorite in a glassy crater"), + (66, 66, "1d4 + 1 wights"), (67, 68, "1 young brass dragon"), + (69, 70, "1 bandit captain with 1d3 berserkers and 3d6 bandits"), + (71, 72, "1 cyclops"), (73, 73, "1d4 couatls"), + (74, 75, "1d4 yuan-ti malisons"), + (76, 80, "Strong winds kick up dust for 1d4 hours"), + (81, 83, "1 revenant with 1d3 wights"), + (84, 85, "1d8 + 1 phase spiders"), (86, 87, "1d6 + 2 weretigers"), + (88, 90, "2d4 gnoll fangs of Yeenoghu"), + (91, 91, "1 young blue dragon"), (92, 92, "1d4 cyclopes"), + (93, 93, "1d3 yuan-ti abominations"), (94, 94, "1d4 medusas"), + (95, 95, "1 guardian naga"), (96, 96, "1d3 young brass dragons"), + (97, 97, "1 efreeti"), (98, 98, "1 roc"), (99, 99, "1 gynosphinx"), + (100, 100, "1 adult brass dragon") + ], + "11-16": + [(1, 1, "1 young brass dragon"), (2, 5, "4d6 gnolls"), + (6, 10, "3d10 giant hyenas"), (11, 12, "1d8 + 1 lamias"), + (13, 14, "2d4 gnoll fangs of Yeenoghu"), + (15, 17, "1d6 + 2 giant scorpions"), (18, 20, "2d4 phase spiders"), + (21, 25, "Caravan: 1d6 merchants with 2d6 guards"), + (26, 27, "1d6 + 1 couatls"), (28, 30, "1d4 fire elementals"), + (31, 32, "1 hobgoblin captain with 3d10 + 10 hobgoblins"), + (33, 35, "2d4 wights"), (36, 40, "1d6 square miles of desert glass"), + (41, 42, "1 young blue dragon"), (43, 45, "1d6 + 2 weretigers"), + (46, 48, "1d4 air elementals"), (49, 50, "1d6 + 1 yuan-ti malisons"), + (51, 55, "1d4 medusas"), + (56, 60, "1d4 revenants with 3d12 skeletons"), + (61, 65, "A plundered pyramid"), (66, 70, "1d4 young brass dragons"), + (71, 75, "1d3 yuan-ti abominations"), (76, 78, "1d6 + 2 cyclopes"), + (79, 82, "1 adult brass dragon"), (83, 85, "1 purple worm"), + (86, 86, "1d2 young blue dragons"), (87, 88, "1 mummy lord"), + (89, 89, "1d3 guardian nagas"), (90, 90, "1 adult blue dragon"), + (91, 91, "1d2 gynosphinxes"), (92, 93, "1d3 efreet"), + (94, 94, "1 androsphinx"), (95, 95, "1d4 rocs"), + (96, 97, "1 adult blue dracolich"), (98, 99, + "1 ancient brass dragon"), + (100, 100, "1 ancient blue dragon")], + "17-20": + [(1, 5, "1 adult brass dragon"), + (6, 10, "1d2 yuan-ti abominations with minions"), + (11, 14, "1d6 + 2 medusas"), (15, 18, "1d2 purple worms"), + (19, 22, "2d4 cyclopes"), + (23, 25, "Abandoned city of white marble. Night apparitions."), + (26, 30, "1d3 young blue dragons"), (31, 35, "1 mummy lord"), + (36, 40, "1d4 hours of extreme heat"), (41, 50, "1d3 guardian nagas"), + (51, 60, "1d4 efreet"), + (61, 63, "Old signpost to destination 'Pazar'"), (64, 72, "1d4 rocs"), + (73, 80, "1d3 gynosphinxes"), (81, 85, "1 adult blue dracolich"), + (86, 90, "1 androsphinx"), (91, 96, "1 ancient brass dragon"), + (97, 99, "1 ancient blue dragon"), + (100, 100, "1d4 adult brass dragons")] + }, + "forest": { + "1-4": + [(1, 1, "1 giant owl"), (2, 2, "1d4 cats"), + (3, 3, "2d4 woodcutters (commoners)"), + (4, 4, "1 badger or 1d4 poisonous snakes"), (5, 5, "2d8 baboons"), + (6, 6, "1d6 + 3 hyenas"), (7, 7, "1 owl"), (8, 8, "1 pseudodragon"), + (9, 9, "1 panther"), (10, 10, "1 giant poisonous snake"), + (11, 11, "1d6 + 2 boars"), (12, 12, "1d4 + 1 giant lizards"), + (13, 13, "1 ape or 1 tiger"), + (14, 14, "2d6 tribal warriors with 1d6 mastiffs"), + (15, 15, "1d6 + 2 giant bats or 3d6 flying snakes"), + (16, 16, "1 scout or 2d4 guards with mastiffs"), + (17, 17, "1d8 + 1 winged kobolds"), + (18, 18, "1d3 constrictor snakes"), (19, 19, "1d10 + 5 giant rats"), + (20, 20, "Needle/Twig blights"), + (21, 25, "A lost child. If helped, reward 1d3 potions of healing."), + (26, 26, "1d8 + 1 giant frogs"), (27, 27, "4d4 kobolds"), + (28, 28, "1d3 black bears"), (29, 29, "3d6 stirges"), + (30, 30, "1 satyr"), (31, 31, "2d4 kenku"), + (32, 32, "Vine blights or awakened shrubs"), + (33, 33, "1d4 swarms of ravens"), (34, 34, "1 faerie dragon"), + (35, 35, "1d4 + 2 giant badgers"), + (36, 40, "A young woodcutter (scout)"), (41, 41, "2d4 blink dogs"), + (42, 42, "1d8 + 1 sprites"), (43, 43, "1d6 + 2 elk"), + (44, 44, "1d4 lizardfolk or 3d6 bandits"), (45, 45, "1d4 + 4 wolves"), + (46, 46, "2d4 giant wolf spiders"), + (47, 47, "Swarm of insects or blood hawks"), + (48, 48, "1d6 + 2 pixies"), (49, 49, "1 brown bear"), + (50, 50, "1d4 + 3 goblins"), (51, 51, "1d3 dryads"), + (52, 52, "1 awakened tree"), (53, 53, "1 phase spider"), + (54, 54, "1d6 harpies"), (55, 55, "1 ettercap or 1d8 + 1 orcs"), + (56, 56, "Goblin boss with goblins"), (57, 57, "1 ankheg"), + (58, 58, "1 giant constrictor snake"), + (59, 59, "1d4 bugbears or 2d4 hobgoblins"), (60, 60, "1 pegasus"), + (61, 65, "A stream of cool, clean water"), + (66, 66, "1d4 half-ogres or 1 ogre"), + (67, 67, "1 faerie dragon (green or older)"), + (68, 68, "1 werewolf or worgs"), + (69, 69, "1 druid harvesting mistletoe"), (70, 70, "1 will-o'-wisp"), + (71, 71, "1d4 dire wolves or 1 giant boar"), + (72, 72, "1d10 giant wasps"), (73, 73, "1 owlbear or 1 giant elk"), + (74, 74, "2d6 gnolls"), (75, 75, "1d6 giant toads"), + (76, 80, "1d6 web cocoons hanging from branches"), + (81, 81, "1 wereboar or giant boars"), + (82, 82, "1d6 + 2 giant spiders"), (83, 83, "1d4 centaurs or elk"), + (84, 84, "Orc Eye of Gruumsh with orcs"), + (85, 85, "1 gnoll fang of Yeenoghu"), (86, 86, "1d4 gricks"), + (87, 87, "Bandit captain with bandits"), (88, 88, "1d4 wererats"), + (89, 89, "1 couatl (day) or 1 banshee (night)"), + (90, 90, "Gnoll pack lord"), + (91, 91, "2d4 berserkers or 1d4 veterans"), + (92, 92, "Lizardfolk shaman with snakes"), + (93, 93, "1d4 displacer beasts"), (94, 94, "1d3 green hags"), + (95, 95, "Hobgoblin captain with minions"), + (96, 96, "1 yuan-ti malison"), (97, 97, "1d3 weretigers"), + (98, 98, "1 gorgon or 1 unicorn"), (99, 99, "1 shambling mound"), + (100, 100, "1 yuan-ti abomination")], + "5-10": + [(1, 1, "2d4 vine blights"), (2, 2, "2d6 hobgoblins or orcs"), + (3, 3, "2d4 apes or satyrs"), (4, 4, "1d3 will-o'-wisps"), + (5, 5, "1d4 swarms of poisonous snakes"), + (6, 6, "Orc Eye of Gruumsh with minions"), + (7, 7, "1d3 constrictor snakes or 1d4 tigers"), + (8, 8, "Goblin boss with 3d6 goblins"), (9, 9, "1 faerie dragon"), + (10, 10, "1 brown bear or black bears"), (11, 13, "1d4 giant boars"), + (14, 15, "1d8 + 1 giant spiders"), + (16, 17, "Lizardfolk shaman with lizardfolk"), + (18, 18, "1d10 giant toads"), (19, 19, "1d4 ankhegs"), + (20, 20, "1d3 awakened trees (day) or banshee (night)"), + (21, 25, "A small shack hidden in forest with cast-iron oven"), + (26, 26, "1 couatl"), (27, 28, "1d4 ogres or half-ogres"), + (29, 30, "Gnoll pack lord with giant hyenas"), + (31, 32, "1d6 wererats"), (33, 33, "1d4 gricks"), + (34, 34, "1d8 + 1 yuan-ti purebloods"), (35, 35, "1d6 pegasi"), + (36, 40, + "Old stone archway. Any passing under gets Perception advantage (1hr)" + ), (41, 42, "1d6 + 2 dryads"), (43, 43, "1d4 giant elk"), + (44, 44, "1d8 + 1 harpies"), + (45, 46, "Bandit captain with druid and bandits"), + (47, 48, "2d4 dire wolves"), (49, 50, "2d4 bugbears"), + (51, 52, "2d4 centaurs"), (53, 54, "3d10 blink dogs"), + (55, 56, "1d4 owlbears"), (57, 58, "1d8 + 1 berserkers"), + (59, 60, "1d3 green hags"), + (61, 65, "A clear pool with sleeping animals"), + (66, 67, "1d4 werewolves"), (68, 69, "1 werebear"), + (70, 71, "1d8 + 1 ettercaps"), (72, 73, "2d10 elk"), + (74, 75, "1d4 veterans"), + (76, 80, "Old tree with wizened face carved into trunk"), + (81, 81, "1d4 wereboars"), (82, 82, "2d4 displacer beasts"), + (83, 83, "1d4 shambling mounds"), + (84, 84, "Hobgoblin captain with huge horde"), + (85, 85, "1 yuan-ti abomination"), (86, 86, "1d8 + 1 phase spiders"), + (87, 87, "1d4 trolls"), (88, 88, "2d4 yuan-ti malisons"), + (89, 89, "1 oni"), (90, 90, "1d4 unicorns"), + (91, 91, "1d6 + 2 weretigers"), (92, 92, "1 young green dragon"), + (93, 93, "1d4 gorgons"), (94, 94, "1d6 + 2 gnoll fangs of Yeenoghu"), + (95, 95, "1 treant"), (96, 96, "1d4 revenants"), + (97, 97, "1 grick alpha with gricks"), (98, 98, "1d4 giant apes"), + (99, 99, "1 guardian naga"), (100, 100, "1 adult gold dragon")], + "11-16": + [(1, 3, "1 werebear"), (4, 5, "1d4 druids (day) or banshees (night)"), + (6, 7, "1d3 couatls"), (8, 10, "1d3 gnoll fangs with gnolls"), + (11, 15, "2d4 displacer beasts"), (16, 20, "1d6 + 2 veterans"), + (21, 25, "Pool of gold coins (they disappear if removed)"), + (26, 30, "1d4 + 1 green hags with owlbears"), + (31, 35, "1d6 + 2 werewolves"), + (36, 40, "Woodland shrine to cult Siswa"), + (41, 45, "1d6 + 2 phase spiders"), (46, 50, "2d4 yuan-ti malisons"), + (51, 52, "1d3 werebears"), (53, 54, "1d4 revenants"), + (55, 56, "1 young green dragon"), (57, 58, "1d4 trolls"), + (59, 60, "1d6 + 2 wereboars"), + (61, 65, "Seven people in animal masks (commoners)"), + (66, 67, "1d4 gorgons"), (68, 69, "1d3 shambling mounds"), + (70, 71, "1 treant"), (72, 73, "1d4 unicorns"), + (74, 75, "1d6 + 2 weretigers"), (76, 80, "Silvery laughter echo"), + (81, 82, "1 guardian naga"), (83, 84, "1 young gold dragon"), + (85, 86, "1 grick alpha with gricks"), + (87, 88, "1d3 yuan-ti abominations"), + (89, 90, "1 adult green dragon"), (91, 93, "1d8 + 1 giant apes"), + (94, 96, "2d4 oni"), (97, 99, "1d3 treants"), + (100, 100, "1 ancient green dragon")], + "17-20": [(1, 5, "1 young green dragon"), (6, 10, "1 treant"), + (11, 13, "1 guardian naga"), (14, 16, "1d10 revenants"), + (17, 19, "1d8 + 1 unicorns"), (20, 22, "1d3 grick alphas"), + (23, 25, "Flowers bloom where characters step"), + (26, 28, "1 young gold dragon"), + (29, 31, "1d6 + 2 shambling mounds"), + (32, 34, "2d4 werebears"), (35, 37, "1d4 oni"), + (38, 40, "Elves in treetop community"), + (41, 43, "1d6 + 2 gorgons"), (44, 46, "2d4 trolls"), + (47, 49, "1d4 giant apes"), + (50, 52, "1d3 yuan-ti abominations"), + (53, 62, "1d3 young green dragons"), + (63, 65, "50-foot stone statue of elf warrior"), + (66, 75, "1d4 treants"), (76, 80, "Cairn atop a low hill"), + (81, 90, "1 adult gold dragon"), + (91, 96, "1 ancient green dragon"), + (97, 99, "2d4 + 1 treants"), + (100, 100, "1 ancient gold dragon")] + }, + "grassland": { + "1-5": + [(1, 1, "1 hobgoblin captain with hobgoblins"), (2, 2, "1 chimera"), + (3, 3, "1 gorgon"), (4, 4, "1d2 couatls"), (5, 5, "1 ankylosaurus"), + (6, 6, "1 weretiger"), (7, 7, "1d3 allosauruses"), + (8, 9, "1d3 elephants"), + (10, 14, "Circle of standing stones with still air"), + (15, 16, "1 phase spider"), + (17, 18, "Gnoll pack lord with giant hyenas"), (19, 20, "1 pegasus"), + (21, 22, "1 ankheg"), (23, 24, "1d3 rhinoceroses"), + (25, 28, "1d3 cockatrices"), + (29, 32, "Giant wasps or swarms of insects"), + (33, 36, "1d4 jackalweres or scouts"), + (37, 40, "1d8 giant goats or worgs"), + (41, 44, "Hobgoblins, orcs, or gnolls"), + (45, 46, "1d2 giant poisonous snakes"), + (47, 48, "Elk or riding horses"), (49, 50, "2d4 goblins"), + (51, 52, "1d3 boars"), (53, 54, "Leopard/Panther/Lion"), + (55, 58, "Goblins riding wolves"), (59, 62, "Giant spiders or eagle"), + (63, 65, "1d8 + 4 pteranodons"), (66, 69, "3d6 wolves"), + (70, 74, "2d4 + 2 axe beaks"), (75, 76, "Giant boar or tigers"), + (77, 78, "1 ogre or bugbears"), (79, 80, "Giant elk or pack lord"), + (81, 82, "Giant vultures or hippogriffs"), + (83, 84, "Goblin boss or thri-kreen"), + (85, 89, "1d3 druids patrolling"), + (90, 91, "1d6 scarecrows or wereboar"), + (92, 93, "Centaurs or griffons"), + (94, 94, "Gnoll fangs or Orc Eye with minions"), + (95, 96, "1 triceratops"), (97, 97, "1 cyclops or bulette"), + (98, 99, "1d4 manticores"), (100, 100, "1 tyrannosaurus rex")], + "6-10": + [(1, 1, "1d3 gorgons"), (2, 2, "1d4 cyclopes"), + (3, 4, "1d3 gnoll fangs of Yeenoghu"), (5, 6, "1 chimera"), + (7, 9, "1d4 + 1 veterans on riding horses"), + (10, 11, "Tornado nearby (1 mile destruction)"), + (12, 13, "1d3 manticores"), (14, 15, "2d4 ankhegs"), + (16, 17, "1d8 + 1 centaurs"), (18, 19, "1d6 + 2 griffons"), + (20, 21, "1d6 elephants"), + (22, 24, "Land littered with war machines and bones"), + (25, 28, "1d8 + 1 bugbears"), + (29, 32, "Gnoll pack lord with giant hyenas"), + (33, 36, "2d4 scarecrows"), (37, 40, "1d12 lions"), + (41, 44, "1d10 thri-kreen"), (45, 46, "1 allosaurus"), + (47, 48, "1 tiger"), (49, 50, "1d2 giant eagles or vultures"), + (51, 52, "Goblin boss with 2d4 goblins"), (53, 54, "1d2 pegasi"), + (55, 58, "1 ankylosaurus"), (59, 62, "1d2 couatls"), + (63, 66, "Orc Eye of Gruumsh with orcs"), (67, 70, "2d4 hippogriffs"), + (71, 74, "1d4 + 1 rhinoceroses"), + (75, 76, "Hobgoblin captain with hobgoblins"), + (77, 78, "1d3 phase spiders"), (79, 80, "1d6 + 2 giant boars"), + (81, 82, "2d4 giant elk"), (83, 84, "1d4 ogres with orogs"), + (85, 87, "Hot wind carrying stench of rot"), + (88, 90, "1d3 weretigers"), (91, 92, "1 bulette"), + (93, 94, "Tribe of 2d20 + 20 nomads. Willing to trade."), + (95, 96, "1d6 + 2 wereboars"), (97, 97, "1 young gold dragon"), + (98, 99, "1d4 triceratopses"), (100, 100, "1d3 tyrannosaurus rexes")], + "11-16": [(1, 5, "3d6 wereboars"), + (6, 10, "2d10 gnoll fangs of Yeenoghu"), + (11, 15, "1d4 bulettes"), + (16, 17, "Old road of paved stones partly reclaimed"), + (18, 27, "1d12 couatls"), + (28, 30, "Witch (mage) in crude hut. Trades potions."), + (31, 40, "2d10 elephants"), (41, 46, "2d4 weretigers"), + (47, 56, "1d8 + 1 cyclopes"), (57, 61, "1d3 chimeras"), + (62, 66, "5 triceratopses"), + (67, 69, "Giant hole 50ft across, 500ft deep"), + (70, 79, "1d4 + 3 gorgons"), + (80, 88, "1d3 young gold dragons"), + (89, 90, "Circular sections of grass pressed down"), + (91, 96, "2d4 tyrannosaurus rexes"), + (97, 99, "1 adult gold dragon"), + (100, 100, "1 ancient gold dragon")], + "17-20": + [(1, 10, "2d6 triceratopses"), (11, 20, "1d10 gorgons"), + (21, 25, "Hyenas feeding on dead dinosaur"), (26, 35, "3d6 bulettes"), + (36, 40, "Fiery chariot races across sky"), + (41, 50, "1d3 young gold dragons"), (51, 60, "2d4 cyclopes"), + (61, 65, "Valley of dead grass and petrified trunks"), + (66, 75, "2d10 wolves"), + (76, 80, "Friendly adventuring party 1d6+1 chars (lvl 1d6+2)"), + (81, 90, "1d12 chimeras"), (91, 96, "1d6 + 2 tyrannosaurus rexes"), + (97, 99, "1 adult gold dragon"), (100, 100, "1 ancient gold dragon")] + }, + "hill": { + "1-4": [ + (1, 1, "1 eagle"), + (2, 3, "2d4 baboons"), + (4, 6, "1d6 bandits"), + (7, 7, "1d4 vultures"), + (8, 8, "1d10 commoners"), + (9, 9, "1 raven"), + (10, 10, "1 poisonous snake"), + (11, 13, "2d6 bandits or 2d6 tribal warriors"), + (14, 14, "2d8 goats"), + (15, 15, "1d6 + 4 blood hawks"), + (16, 16, "1d4 + 3 giant weasels"), + (17, 18, "1d3 guards with 1d2 mastiffs and 1 mule"), + (19, 20, "1d6 + 5 hyenas"), + (21, 22, "2d4 stirges"), + (23, 25, "An empty cave littered with bones"), + (26, 26, "1 pseudodragon or 1d3 giant owls"), + (27, 27, "1 lion or 1 cougar (panther)"), + (28, 30, "2d8 kobolds"), + (31, 31, "1 hippogriff"), + (32, 34, "2d4 goblins"), + (35, 35, "1 worg"), + (36, 36, "1d3 swarms of bats or 1d3 swarms of ravens"), + (37, 37, "1 giant eagle"), + (38, 40, "An old dwarf sitting on a stump, whittling a piece of wood"), + (41, 41, "1d4 elk"), + (42, 42, "1d4 winged kobolds with 1d6 kobolds"), + (43, 43, "1d6 + 2 giant wolf spiders"), + (44, 45, "2d4 wolves"), + (46, 46, "1 swarm of insects"), + (47, 47, "1d8 + 1 axe beaks"), + (48, 49, "1 brown bear or 1d3 boars"), + (50, 50, "1 scout"), + (51, 51, "1 ogre"), + (52, 53, "2d4 gnolls"), + (54, 54, "1 giant elk"), + (55, 55, "1d3 + 1 harpies"), + (56, 56, "1 werewolf"), + (57, 58, "2d4 orcs"), + (59, 59, "1d4 half-ogres"), + (60, 60, "1 druid or 1 veteran"), + (61, 63, "The corpse of an adventurer that carries an intact explorer's pack and lies atop a longsword"), + (64, 64, "1 green hag"), + (65, 66, "1d3 dire wolves"), + (67, 68, "A small cemetery containing 2d6 graves"), + (69, 70, "1 hobgoblin captain with 2d4 hobgoblins"), + (71, 71, "2d4 giant goats"), + (72, 72, "1 manticore"), + (73, 74, "1d6 + 2 hobgoblins"), + (75, 75, "1 phase spider"), + (76, 78, "A pile of droppings from a very large bird"), + (79, 79, "1 gnoll fang of Yeenoghu"), + (80, 80, "1d3 giant boars"), + (81, 81, "1 gnoll pack lord with 1d3 giant hyenas"), + (82, 82, "1 bandit captain with 2d4 bandits"), + (83, 83, "1 orc Eye of Gruumsh with 1d8 + 2 orcs"), + (84, 84, "1d3 orogs or 1d4 berserkers"), + (85, 86, "1 ettin or 1 wereboar"), + (87, 88, "1 goblin boss with 2d6 goblins"), + (89, 89, "1d3 griffons"), + (90, 90, "1d3 perytons or 1d4 pegasi"), + (91, 96, "1d3 trolls"), + (97, 99, "1 cyclops"), + (100, 100, "1 stone giant") + ], + "5-10": [ + (1, 1, "1d4 pegasi or 1d3 perytons"), + (2, 2, "1d6 + 2 giant goats"), + (3, 3, "1 manticore"), + (4, 4, "1d8 + 1 gnolls or 1d8 + 1 hobgoblins"), + (5, 5, "1d4 lions"), + (6, 6, "1d6 + 2 worgs"), + (7, 7, "1d4 brown bears"), + (8, 8, "3d6 axe beaks"), + (9, 9, "1 half-ogre with 2d6 orcs"), + (10, 10, "2d10 winged kobolds"), + (11, 12, "1 goblin boss with 1d4 dire wolves and 2d6 goblins"), + (13, 13, "1d6 giant elk"), + (14, 15, "1d8 + 1 giant eagles"), + (16, 17, "1d4 phase spiders"), + (18, 19, "1 gnoll pack lord with 2d4 giant hyenas"), + (20, 20, "2d4 hippogriffs"), + (21, 25, "A 15-foot-tall stone statue of a dwarf warrior that has been tipped over on its side"), + (26, 27, "2d4 orogs"), + (28, 29, "1d4 + 1 griffons"), + (30, 31, "1d6 + 2 harpies"), + (32, 33, "1 orc Eye of Gruumsh with 2d6 + 3 orcs"), + (34, 35, "1d4 + 3 giant boars"), + (36, 40, "A stone door set into the side of a steep hill, opening onto 15 feet of descending stairs that end at a cave-in"), + (41, 42, "1d3 green hags"), + (43, 44, "1d4 werewolves"), + (45, 46, "1d6 + 2 ogres"), + (47, 48, "1 hobgoblin captain with 2d8 hobgoblins"), + (49, 50, "1 bandit captain with 3d6 bandits"), + (51, 54, "1 chimera"), + (55, 58, "1d4 ettins"), + (59, 62, "1d6 + 2 veterans with 2d6 berserkers"), + (63, 65, "An abandoned wooden hut"), + (66, 69, "1 galeb duhr"), + (70, 73, "1 bulette"), + (74, 77, "1 wyvern"), + (78, 80, "2d6 + 10 goats with 1 herder (tribal warrior)"), + (81, 82, "1d3 hill giants"), + (83, 84, "2d4 wereboars"), + (85, 86, "1d4 revenants"), + (87, 88, "1d2 gorgons"), + (89, 90, "1d8 + 1 gnoll fangs of Yeenoghu"), + (91, 93, "1d4 cyclopes"), + (94, 96, "1 young red dragon"), + (97, 98, "1d4 stone giants"), + (99, 99, "1d3 young copper dragons"), + (100, 100, "1 roc") + ], + "11-16": [ + (1, 2, "2d8 manticores or 2d8 phase spiders"), + (3, 4, "1d6 green hags with 1d6 wyverns"), + (5, 7, "1 hobgoblin captain with 1 hill giant and 4d10 hobgoblins"), + (8, 10, "2d6 + 3 werewolves"), + (11, 14, "1d6 + 2 ettins"), + (15, 18, "1d3 bulettes"), + (19, 22, "1d4 werebears"), + (23, 24, "A stream of smoke emerging from a small chimney in the hillside"), + (25, 28, "1d4 wyverns"), + (29, 32, "1d8 + 1 wereboars"), + (33, 36, "1d3 revenants"), + (37, 38, "A mild earthquake that shakes the region for 1d20 seconds"), + (39, 42, "1d3 chimeras"), + (43, 46, "1d4 gorgons"), + (47, 50, "1d6 + 2 gnoll fangs of Yeenoghu"), + (51, 54, "1d4 hill giants"), + (55, 58, "1 young red dragon"), + (59, 62, "1d3 + 1 galeb duhr"), + (63, 65, "2d10 dwarf miners (commoners), whistling as they march toward their mine"), + (66, 69, "1d3 young copper dragons"), + (70, 73, "1d4 trolls"), + (74, 77, "1d3 cyclopes"), + (78, 80, "1d3 nobles with 1d4 scouts prospecting for gold"), + (81, 85, "1 adult copper dragon"), + (86, 90, "2d4 stone giants"), + (91, 96, "1d4 rocs"), + (97, 99, "1 adult red dragon"), + (100, 100, "1 ancient copper dragon") + ], + "17-20": [ + (1, 1, "1d2 rocs"), + (2, 5, "1 young red dragon"), + (6, 10, "2d6 ettins"), + (11, 15, "1d4 bulettes"), + (16, 20, "1d10 revenants"), + (21, 25, "The white outline of an enormous horse carved into the side of a high hill"), + (26, 30, "1d6 + 1 gorgons"), + (31, 35, "2d4 + 1 trolls"), + (36, 40, "The scorched remains of 2d10 humanoids littering a hillside"), + (41, 45, "2d4 hill giants"), + (46, 50, "1d6 + 2 werebears"), + (51, 55, "2d4 galeb duhr"), + (56, 60, "1d4 + 2 wyverns"), + (61, 65, "A massive boulder partly buried in the earth as if it fell or was thrown there"), + (66, 70, "1 adult copper dragon"), + (71, 75, "1d6 + 3 cyclopes"), + (76, 80, "The stub of an old stone tower jutting from the top of a hill"), + (81, 85, "2d4 stone giants"), + (86, 90, "1 adult red dragon"), + (91, 96, "1 ancient copper dragon"), + (97, 99, "1 ancient red dragon"), + (100, 100, "1d2 adult red dragons with 1d3 young red dragons") + ] + }, + + "mountain": { + "1-4": [ + (1, 2, "1 eagle"), + (3, 5, "1d3 swarms of bats"), + (6, 8, "1d6 goats"), + (9, 11, "1d10 + 5 tribal warriors"), + (12, 14, "1d6 + 3 pteranodons"), + (15, 17, "1d8 + 1 winged kobolds"), + (18, 20, "1 lion"), + (21, 24, "Stairs chiseled into the side of the mountain that climb 3d20 + 40 feet before ending abruptly"), + (25, 27, "2d10 stirges"), + (28, 30, "2d4 aarakocra"), + (31, 33, "2d6 dwarf soldiers (guards) with 1d6 mules laden with iron ore"), + (34, 36, "1 giant eagle"), + (37, 38, "A small shrine dedicated to a lawful neutral god, perched on a stone outcropping"), + (39, 41, "2d8 + 1 blood hawks"), + (42, 44, "1 giant goat"), + (45, 47, "3d4 kobolds"), + (48, 50, "1 half-ogre"), + (51, 53, "1 berserker"), + (54, 55, "1 orog"), + (56, 56, "1 hell hound"), + (57, 57, "1 druid"), + (58, 59, "1 peryton"), + (60, 61, "1d2 hippogriffs"), + (62, 62, "1 manticore"), + (63, 64, "1d6 + 2 scouts"), + (65, 67, "Enormous footprints left by a giant, which head into the mountain peaks"), + (68, 73, "2d4 orcs"), + (74, 75, "1 giant elk"), + (76, 77, "1 veteran"), + (78, 79, "1 orc Eye of Gruumsh"), + (80, 80, "1d4 harpies"), + (81, 81, "1 ogre"), + (82, 82, "1 griffon"), + (83, 83, "1 basilisk"), + (84, 85, "1 saber-toothed tiger"), + (86, 90, "A sparkling stream of water spilling from a crevice"), + (91, 91, "1d2 ettins"), + (92, 92, "1 cyclops"), + (93, 93, "1 troll"), + (94, 94, "1 galeb duhr"), + (95, 95, "1 air elemental"), + (96, 96, "1 bulette"), + (97, 97, "1 chimera"), + (98, 98, "1 wyvern"), + (99, 99, "1 stone giant"), + (100, 100, "1 frost giant") + ], + "5-10": [ + (1, 2, "2d8 + 1 aarakocra"), + (3, 4, "1 lion or 1 saber-toothed tiger"), + (5, 6, "1d8 + 1 giant goats"), + (7, 8, "1d4 + 3 dwarf trailblazers (scouts)"), + (9, 10, "1d6 + 2 orcs"), + (11, 15, "1d10 giant eagles"), + (16, 20, "1d8 + 1 hippogriffs"), + (21, 25, "1d8 fissures venting steam that partially obscures a 20-foot cube above each fissure"), + (26, 30, "1 basilisk"), + (31, 35, "1d12 half-ogres"), + (36, 40, "A ravine blocked by a 100-foot-high wall, which has an opening in the center where a gate used to be"), + (41, 45, "1 manticore"), + (46, 50, "2d4 harpies"), + (51, 52, "1 galeb duhr"), + (53, 54, "1 bulette"), + (55, 56, "1d10 berserkers"), + (57, 58, "1d3 hell hounds"), + (59, 60, "1d8 + 1 veterans"), + (61, 65, "A distant mountain whose peak resembles a tooth"), + (66, 69, "1d4 ettins"), + (70, 73, "1 wyvern"), + (74, 75, "1 orc Eye of Gruumsh with 1d6 orogs and 3d6 + 10 orcs"), + (76, 80, "A row of 1d10 + 40 stakes upon which the bodies of kobolds, dwarves, or orcs are impaled"), + (81, 83, "1 fire giant"), + (84, 85, "1 young silver dragon"), + (86, 87, "1d4 air elementals"), + (88, 90, "1d4 trolls"), + (91, 92, "1d3 + 1 cyclopes"), + (93, 94, "1d4 chimeras"), + (95, 96, "1 cloud giant"), + (97, 97, "1 roc"), + (98, 98, "1d4 stone giants"), + (99, 99, "1 young red dragon"), + (100, 100, "1d4 frost giants") + ], + "11-16": [ + (1, 2, "1d8 + 1 basilisks"), + (3, 4, "2d4 hell hounds"), + (5, 6, "1d3 chimeras"), + (7, 8, "1 galeb duhr"), + (9, 10, "2d6 veterans"), + (11, 15, "1 young silver dragon"), + (16, 20, "2d4 trolls"), + (21, 25, "1 red dragon gliding through the sky above the highest mountaintops"), + (26, 30, "1d8 + 1 manticores"), + (31, 35, "1d4 cyclopes"), + (36, 40, "Heavy snowfall that lasts for 1d6 hours"), + (41, 45, "1d10 air elementals"), + (46, 50, "1d6 + 2 bulettes"), + (51, 55, "1d4 stone giants"), + (56, 60, "1 fire giant"), + (61, 65, "2 stone giants playing catch with a boulder a few hundred feet away"), + (66, 70, "1d8 + 1 ettins"), + (71, 75, "1d3 frost giants"), + (76, 80, "A wide crevasse, its depths shrouded in mist"), + (81, 85, "1d4 cloud giants"), + (86, 90, "1 adult silver dragon"), + (91, 96, "1 adult red dragon"), + (97, 98, "1d4 rocs"), + (99, 99, "1 ancient silver dragon"), + (100, 100, "1 ancient red dragon") + ], + "17-20": [ + (1, 5, "1d10 bulettes"), + (6, 10, "1d8 + 1 chimeras"), + (11, 15, "1 adult silver dragon"), + (16, 20, "1d8 + 1 wyverns"), + (21, 25, "A massive boat perched atop a mountain"), + (26, 30, "2d4 galeb duhr"), + (31, 35, "1d4 frost giants"), + (36, 40, "A wooded valley haunted by secretive and reclusive elves who tell warily of their master: a mad wizard"), + (41, 45, "1d10 air elementals"), + (46, 50, "1d6 + 3 trolls"), + (51, 55, "1 adult red dragon"), + (56, 60, "1d4 cloud giants"), + (61, 65, "A waterfall hundreds of feet high that drops into a clear pool"), + (66, 70, "1d3 fire giants"), + (71, 75, "2d4 stone giants"), + (76, 80, "A force of 100 dwarves (veterans) standing guard at a mountain pass, permitting no passage until a traveler pays 100 gp (if on foot) or 200 gp (if mounted)"), + (81, 85, "1d4 rocs"), + (86, 90, "1d4 young red dragons"), + (91, 96, "1 ancient silver dragon"), + (97, 100, "1 ancient red dragon") + ] + }, + + "swamp": { + "11-20": [ + (1, 10, "1d4 giant crocodiles"), + (11, 15, "1d3 yuan-ti abominations"), + (16, 20, "1d6 + 1 green hags"), + (21, 25, "A large, spreading tree from which 2d6 armored knights hang by the neck"), + (26, 30, "2d4 wights"), + (31, 35, "1d8 + 1 yuan-ti malisons: type 1, type 2, type 3"), + (36, 40, "Fog that rolls across the terrain, making the area within 1d3 miles heavily obscured for 1d4 hours"), + (41, 45, "1d4 revenants"), + (46, 50, "1d6 shambling mounds"), + (51, 55, "1d10 water elementals"), + (56, 60, "1d4 young black dragons"), + (61, 65, "An eerie, bat-headed idol almost completely covered by vines"), + (66, 70, "1d8 + 2 trolls"), + (71, 75, "1d3 hydras"), + (76, 80, "The sound of drums beating several miles away"), + (81, 96, "1 adult black dragon"), + (97, 100, "1 ancient black dragon") + ] + }, + + "underdark": { + "1-4": [ + (1, 1, "1 mind flayer arcanist"), + (2, 3, "1d3 + 1 giant poisonous snakes"), + (4, 5, "1d3 giant lizards"), + (6, 7, "2d4 giant fire beetles"), + (8, 8, "1d8 + 1 flumphs"), + (9, 9, "1 shrieker"), + (10, 10, "1d12 giant rats"), + (11, 11, "2d4 kobolds"), + (12, 12, "1d8 + 1 stirges"), + (13, 13, "2d4 humans (tribal warriors) seeking the way to the surface"), + (14, 15, "1d10 troglodytes"), + (16, 17, "1d2 gray oozes"), + (18, 19, "3d6 stirges"), + (20, 21, "1d3 magma mephits"), + (22, 23, "1d10 goblins"), + (24, 25, "Orc graffiti on the walls, suggesting something rude about the mother of someone named Krusk"), + (26, 26, "1 swarm of insects"), + (27, 27, "1 deep gnome"), + (28, 29, "1d8 + 1 drow"), + (30, 31, "1d4 violet fungi"), + (32, 33, "1d12 kuo-toa"), + (34, 34, "1 rust monster"), + (35, 36, "A rubble-strewn passage that appears to have been recently cleared after a cave-in"), + (37, 38, "1d8 + 1 giant bats"), + (39, 40, "3d6 kobolds"), + (41, 42, "2d4 grimlocks"), + (43, 44, "1d4 + 3 swarms of bats"), + (45, 45, "1 dwarf prospector (scout) looking for gold"), + (46, 46, "1 carrion crawler or 1 gelatinous cube"), + (47, 47, "1d8 darkmantles or 2d4 piercers"), + (48, 48, "1 hell hound"), + (49, 49, "1d3 specters"), + (50, 50, "1d4 bugbears"), + (51, 51, "1d10 + 5 winged kobolds"), + (52, 52, "1d4 fire snakes"), + (53, 53, "2d8 + 1 troglodytes"), + (54, 54, "1d6 giant spiders"), + (55, 55, "3d6 kuo-toa"), + (56, 56, "1 goblin boss with 2d4 goblins"), + (57, 57, "4d4 grimlocks"), + (58, 58, "1 ochre jelly"), + (59, 59, "2d10 giant centipedes"), + (60, 60, "1 nothic or 1 giant toad"), + (61, 61, "1d4 myconid adults with 5d4 myconid sprouts"), + (62, 62, "1 minotaur skeleton or 1 minotaur"), + (63, 63, "3d6 drow"), + (64, 64, "1 mimic or 1 doppelganger"), + (65, 65, "1d6 + 3 hobgoblins"), + (66, 66, "1 intellect devourer or 1 spectator"), + (67, 68, "A faint tapping coming from inside a nearby wall"), + (69, 69, "1 gibbering mouther or 1 water weird"), + (70, 70, "1d12 gas spores"), + (71, 71, "1 giant constrictor snake"), + (72, 72, "1d10 shadows"), + (73, 73, "1d3 grells"), + (74, 74, "1d4 wights"), + (75, 75, "1d8 + 1 quaggoth spore servants"), + (76, 76, "1d2 gargoyles"), + (77, 77, "1d4 ogres or 1d3 ettins"), + (78, 78, "1d4 dwarf explorers (veterans)"), + (79, 80, "An abandoned miners' camp spattered with blood and littered with the contents of 1d3 dungeoneer's packs"), + (81, 81, "1 chuul or 1 salamander"), + (82, 82, "1d4 phase spiders or 1d3 hook horrors"), + (83, 83, "5d4 duergar"), + (84, 84, "1 ghost or 1 flameskull or 1 wraith"), + (85, 85, "1 druid with 1 polar bear (cave bear)"), + (86, 86, "1 hobgoblin captain with 1d4 half-ogres and 2d10 hobgoblins"), + (87, 87, "1 earth elemental or 1 black pudding"), + (88, 88, "1 kuo-toa monitor with 1d8 + 1 kuo-toa whips"), + (89, 89, "1 quaggoth thonot with 1d3 quaggoths"), + (90, 90, "1 beholder zombie or 1 bone naga"), + (91, 91, "1 orc Eye of Gruumsh with 1d4 orogs and 2d8 orcs"), + (92, 92, "1d4 ghasts with 1d10 ghouls"), + (93, 95, "A reeking puddle where slimy water has dripped from the ceiling"), + (96, 96, "1 otyugh or 1 roper"), + (97, 97, "1 vampire spawn"), + (98, 98, "1 chimera"), + (99, 99, "1 mind flayer"), + (100, 100, "1 spirit naga") + ], + "5-10": [ + (1, 1, "3d6 swarms of bats"), + (2, 2, "1d4 giant spiders or 1d4 giant toads"), + (3, 3, "1 mimic"), + (4, 4, "2d4 grey oozes"), + (5, 5, "2d10 orcs or 3d6 troglodytes"), + (6, 6, "3d6 grimlocks"), + (7, 7, "1d6 + 2 magma mephits"), + (8, 8, "1 goblin boss with 2d4 goblins"), + (9, 9, "2d4 darkmantles"), + (10, 10, "2d8 + 1 drow"), + (11, 11, "2d10 piercers"), + (12, 12, "1d4 minotaur skeletons"), + (13, 14, "3d6 deep gnomes"), + (15, 15, "1 druid with 1 polar bear (cave bear)"), + (16, 17, "3d6 orcs"), + (18, 18, "1 bone naga"), + (19, 20, "2d6 bugbears"), + (21, 25, "Luminescent fungi growing on the walls of a moist cave, filling it with dim light"), + (26, 26, "2d4 specters"), + (27, 27, "1d12 + 4 shadows"), + (28, 28, "1d3 gibbering mouthers"), + (29, 30, "4d4 hobgoblins"), + (31, 32, "1d4 carrion crawlers"), + (33, 34, "1 black pudding"), + (35, 35, "1d4 ochre jellies"), + (36, 40, "A patch of mold that appears yellow when light is directed toward it"), + (41, 41, "1d4 nothics"), + (42, 43, "2d8 + 1 gas spores"), + (44, 45, "1d3 gelatinous cubes"), + (46, 46, "1 ghost"), + (47, 48, "1 flameskull"), + (49, 50, "2d8 duergar"), + (51, 51, "1 wraith"), + (52, 52, "1 umber hulk"), + (53, 53, "1 xorn"), + (54, 54, "1d6 + 2 dwarf hunters (veterans) searching for trolls"), + (55, 55, "1 hobgoblin captain with 3d10 hobgoblins"), + (56, 56, "1 roper"), + (57, 57, "1 kuo-toa monitor with 1d4 kuo-toa whips and 1d8 + 1 kuo-toa"), + (58, 58, "1d3 water weirds"), + (59, 59, "1d4 ghasts with 1d10 ghouls"), + (60, 60, "1 otyugh"), + (61, 62, "A merchant caravan consisting of 1 drow mage, 2 drow elite warriors, and 2d10 quaggoths"), + (63, 63, "1d4 wights"), + (64, 64, "1d4 doppelgangers"), + (65, 65, "2d8 fire snakes"), + (66, 66, "1d4 spectators"), + (67, 67, "1 orc Eye of Gruumsh with 1d4 orogs and 2d10 + 3 orcs"), + (68, 68, "1d3 vampire spawn"), + (69, 69, "1d4 hook horrors or 1d4 minotaurs"), + (70, 70, "3d6 quaggoth spore servants"), + (71, 72, "1d3 grells"), + (73, 73, "1d6 + 1 intellect devourers"), + (74, 74, "1d10 gargoyles"), + (75, 75, "1 beholder zombie"), + (76, 77, "1 quaggoth thonot with 2d4 quaggoths"), + (78, 78, "1d6 ettins or 1d4 trolls"), + (79, 79, "1d8 + 1 phase spiders"), + (80, 80, "1 fomorian or 1d3 cyclopes"), + (81, 81, "1d4 earth elementals"), + (82, 82, "3d6 ogres"), + (83, 83, "1d4 + 1 chuuls"), + (84, 84, "1d10 hell hounds"), + (85, 85, "1d3 drow elite warriors"), + (86, 86, "1d4 chimeras"), + (87, 87, "1d4 salamanders"), + (88, 88, "1 cloaker"), + (89, 89, "2d4 wights"), + (90, 90, "1d4 driders"), + (91, 91, "1 fire giant"), + (92, 92, "1 grick alpha with 2d4 gricks"), + (93, 93, "1 mind flayer arcanist"), + (94, 94, "1d4 drow mages"), + (95, 95, "1 spirit naga"), + (96, 96, "1d4 mind flayers"), + (97, 97, "1 behir"), + (98, 98, "1 aboleth"), + (99, 99, "1 dao or 1 stone giant"), + (100, 100, "1 beholder") + ], + "11-16": [ + (1, 2, "3d6 carrion crawlers"), + (3, 4, "1d6 + 1 gelatinous cubes"), + (5, 6, "1d8 + 2 gibbering mouthers"), + (7, 8, "2d8 minotaur skeletons"), + (9, 10, "2d6 ochre jellies"), + (11, 12, "2d4 doppelgangers"), + (13, 14, "1d4 quaggoth thonots with 1d10 + 2 quaggoths"), + (15, 16, "1d3 ropers"), + (17, 18, "3d6 gargoyles"), + (19, 20, "1d10 mimics"), + (21, 25, "A 100-foot-long ravine, 4d10 feet wide and 5d20 + 200 feet deep"), + (26, 27, "1 hobgoblin captain with 3d10 hobgoblins"), + (28, 29, "2d4 spectators"), + (30, 31, "3d6 ghasts"), + (32, 33, "2d8 intellect devourers"), + (34, 35, "1d3 orc Eyes of Gruumsh with 2d4 orogs and 2d10 orcs"), + (36, 40, "A large cave containing 2d10 extraordinarily detailed statues of various creatures"), + (41, 42, "1d8 + 1 kuo-toa monitors"), + (43, 44, "2d4 water weirds"), + (45, 46, "2d10 gricks"), + (47, 48, "3d6 nothics"), + (49, 50, "2d8 + 1 ogres"), + (51, 52, "1d6 + 2 chuuls"), + (53, 54, "1d8 + 1 ettins"), + (55, 55, "3d6 grells"), + (56, 56, "2d4 flameskulls"), + (57, 57, "2d12 dwarf soldiers (veterans) on patrol"), + (58, 58, "2d8 hell hounds"), + (59, 59, "1d10 ghosts"), + (60, 60, "3d4 wights"), + (61, 61, "3d6 phase spiders"), + (62, 62, "1d8 + 1 bone nagas"), + (63, 65, "A shrill scream followed by dark laughter"), + (66, 66, "1d4 chimeras"), + (67, 67, "1d10 black puddings"), + (68, 68, "3d6 minotaurs"), + (69, 69, "2d4 otyughs"), + (70, 70, "1d6 + 1 beholder zombies"), + (71, 71, "4d4 hook horrors"), + (72, 72, "1d8 + 1 umber hulks"), + (73, 73, "2d4 salamanders"), + (74, 74, "1d3 grick alphas"), + (75, 75, "1d6 + 2 xorn"), + (76, 80, "A ruined village that once belonged to deep gnomes. A search has a 50% chance of uncovering 1d3 potions of healing and a 25% chance of finding a random common magic item"), + (81, 81, "2d4 earth elementals"), + (82, 82, "1d3 spirit nagas"), + (83, 83, "1d8 + 1 cyclopes"), + (84, 84, "1d6 + 2 trolls"), + (85, 85, "2d4 stone giants"), + (86, 86, "2d4 wraiths"), + (87, 87, "1d4 fomorians"), + (88, 88, "1d3 drow mages with 1d4 drow elite warriors"), + (89, 89, "1d10 vampire spawn"), + (90, 90, "1d3 cloakers"), + (91, 91, "1d4 fire giants"), + (92, 92, "1 mind flayer arcanist with 1d6 + 1 mind flayers"), + (93, 93, "1d4 dao"), + (94, 94, "1d8 + 1 driders"), + (95, 95, "1d3 behirs"), + (96, 96, "1d4 aboleths"), + (97, 97, "1 beholder"), + (98, 98, "1 young red shadow dragon"), + (99, 99, "1 death tyrant"), + (100, 100, "1 purple worm") + ], + "17-20": [ + (1, 1, "1d4 grick alphas"), + (2, 2, "2d8 spectators"), + (3, 4, "3d6 minotaurs or 2d8 kuo-toa monitors"), + (5, 6, "2d8 grells"), + (7, 8, "2d10 phase spiders"), + (9, 10, "4d4 hell hounds"), + (11, 12, "1d6 + 2 ropers"), + (13, 14, "2d10 wights"), + (15, 16, "3d6 doppelgangers"), + (17, 18, "1d8 + 1 chimeras"), + (19, 20, "1d4 cloakers"), + (21, 21, "1d4 hobgoblin captains with 5d10 hobgoblins"), + (22, 23, "1d8 + 1 earth elementals"), + (24, 25, "2d4 vampire spawn"), + (26, 27, "3d6 minotaurs"), + (28, 30, "A 30-foot-tall inverted black pyramid floating 1 inch above the floor in a large cave"), + (31, 32, "1d10 beholder zombies"), + (33, 34, "1d4 mind flayer arcanists"), + (35, 36, "1d6 + 2 otyughs"), + (37, 38, "1d12 trolls"), + (39, 40, "1d10 wraiths"), + (41, 43, "A beautiful obsidian sculpture of a panther lying on the floor"), + (44, 45, "1d4 drow mages with 1d6 drow elite warriors"), + (46, 47, "1d4 spirit nagas"), + (48, 49, "1d8 + 1 salamanders"), + (50, 51, "2d4 umber hulks"), + (52, 53, "1d10 xorn"), + (54, 56, "1 young red shadow dragon"), + (57, 59, "2d4 fomorians"), + (60, 62, "1d8 + 1 driders"), + (63, 65, "1d20 + 20 spiders crawling on the walls of a web-filled cave"), + (66, 68, "1d4 fire giants"), + (69, 70, "1d10 mind flayers"), + (71, 73, "2d4 stone giants"), + (74, 76, "1d12 cyclopes"), + (77, 80, "A large cave in which stands a 50-foot-tall idol of Blibdoolpoolp"), + (81, 85, "1d3 dao"), + (86, 90, "1d4 beholders"), + (91, 93, "1d4 behirs"), + (94, 96, "1 death tyrant"), + (97, 99, "1d3 purple worms"), + (100, 100, "2d4 aboleths") + ] + }, + + "underwater": { + "11-20": [ + (1, 10, "1 sahuagin baron with 1d4 sahuagin priestesses and 2d10 sahuagin"), + (11, 35, "1d10 killer whales"), + (36, 40, "A ghost ship passing overhead, containing 2d6 + 10 ghosts"), + (41, 60, "1d6 giant sharks"), + (61, 65, "A 1-mile-radius sphere of effervescent water that allows air-breathing creatures to breathe water while in the sphere"), + (66, 75, "1d10 water elementals"), + (76, 80, "A shimmering, blue-green portal to the Elemental Plane of Water"), + (81, 90, "1d4 marids"), + (91, 96, "1d3 storm giants"), + (97, 99, "1 dragon turtle"), + (100, 100, "1 kraken") + ] + }, + + "urban": { + "1-4": [ + (1, 1, "1d6 cats"), + (2, 3, "1 commoner with 1d6 goats"), + (4, 5, "2d10 rats"), + (6, 6, "1 raven perched on a signpost"), + (7, 7, "1 commoner on a draft horse"), + (8, 8, "2d4 mastiffs"), + (9, 9, "1d2 commoners leading 1d4 mules or 1d4 ponies"), + (10, 10, "1 pseudodragon"), + (11, 11, "1 spy"), + (12, 13, "1d8 + 1 acolytes"), + (14, 14, "1d6 + 6 flying snakes"), + (15, 15, "3d6 kobolds"), + (16, 16, "2d4 giant centipedes"), + (17, 17, "1d8 + 1 skeletons"), + (18, 19, "1d6 + 2 swarms of rats"), + (20, 20, "1d12 zombies"), + (21, 25, "A peddler weighed down with a load of pots, pans, and other basic supplies"), + (26, 26, "1 giant wasp"), + (27, 28, "1 warhorse"), + (29, 29, "2d8 cultists"), + (30, 31, "3d4 giant rats"), + (32, 32, "2d8 stirges"), + (33, 33, "1d3 + 2 giant poisonous snakes"), + (34, 34, "1d4 + 2 swarms of bats"), + (35, 35, "2d4 winged kobolds"), + (36, 40, "A wagon loaded with apples that has a broken wheel and holds up traffic"), + (41, 41, "1 crocodile"), + (42, 43, "1 swarm of insects"), + (44, 45, "3d6 bandits"), + (46, 47, "1d3 + 2 nobles on riding horses with an escort of 1d10 guards"), + (48, 48, "2d4 kenku"), + (49, 49, "1d6 + 2 smoke mephits"), + (50, 50, "1d8 + 1 swarms of ravens"), + (51, 52, "1 wererat"), + (53, 54, "1d3 half-ogres"), + (55, 56, "1 mimic"), + (57, 58, "1d4 ghouls"), + (59, 60, "1d4 specters"), + (61, 62, "1d10 shadows"), + (63, 65, "Someone empties a chamber pot onto the street from a second-floor window"), + (66, 67, "1 ghast"), + (68, 69, "1 priest"), + (70, 71, "1 will-o'-wisp"), + (72, 73, "1d3 giant spiders"), + (74, 75, "1d4 yuan-ti purebloods"), + (76, 77, "2d4 thugs"), + (78, 80, "A doomsayer who preaches the end of the world from a street corner"), + (81, 81, "1 cambion"), + (82, 82, "1 vampire spawn"), + (83, 83, "1 couatl"), + (84, 84, "1 ghost"), + (85, 85, "1 succubus or 1 incubus"), + (86, 86, "1 bandit captain with 3d6 bandits"), + (87, 87, "1d4 + 1 cult fanatics"), + (88, 88, "1 knight or 1 veteran"), + (89, 89, "1 water weird"), + (90, 90, "1 wight"), + (91, 91, "1 mage"), + (92, 92, "1 shield guardian"), + (93, 93, "1 gladiator"), + (94, 94, "1 revenant"), + (95, 95, "2d4 gargoyles"), + (96, 96, "1d4 doppelgangers"), + (97, 97, "1 oni"), + (98, 98, "1 invisible stalker"), + (99, 99, "1d3 + 1 phase spiders"), + (100, 100, "1 assassin") + ], + "5-10": [ + (1, 2, "1d10 kenku"), + (3, 4, "2d6 giant centipedes"), + (5, 6, "2d8 skeletons"), + (7, 8, "1d6 swarms of bats and 1d6 swarms of rats"), + (9, 10, "3d6 winged kobolds"), + (11, 13, "2d4 specters"), + (14, 16, "1d4 wights"), + (17, 19, "4d4 acolytes on draft horses"), + (20, 22, "3d6 giant centipedes"), + (23, 25, "A talkative urchin, badgering passersby to serve as their guide through the community for a price of 1 sp"), + (26, 28, "1d10 spies"), + (29, 31, "3d6 crocodiles"), + (32, 34, "1d6 + 2 swarms of insects"), + (35, 37, "2d4 smoke mephits"), + (38, 40, "A noble shouts 'Stop! Thief!' at a fleeing scoundrel (bandit)"), + (41, 43, "1 succubus or 1 incubus"), + (44, 46, "1d10 half-ogres"), + (47, 49, "2d10 giant wasps"), + (50, 51, "4d10 zombies"), + (52, 53, "1d4 knights on warhorses"), + (54, 55, "1d4 + 1 water weirds"), + (56, 57, "1d8 + 1 mimics"), + (58, 59, "2d8 giant spiders"), + (60, 61, "3d6 shadows"), + (62, 65, "An actor leans out from a second-story window to call to passersby, announcing a show"), + (66, 67, "1 bandit captain with 3d8 bandits"), + (68, 69, "1d10 will-o'-wisps"), + (70, 71, "2d4 priests"), + (72, 74, "3d6 yuan-ti purebloods"), + (75, 76, "2d10 thugs"), + (77, 80, "A fortune-teller reads cards for those who pay a price of 1 sp"), + (81, 81, "1d3 gladiators"), + (82, 82, "1d4 + 1 couatls"), + (83, 83, "1d8 ghosts"), + (84, 84, "2d4 doppelgangers"), + (85, 85, "1d6 + 2 phase spiders"), + (86, 86, "2d4 veterans"), + (87, 87, "1d8 ghasts with 2d6 ghouls"), + (88, 88, "3d6 gargoyles"), + (89, 89, "2d10 cult fanatics"), + (90, 90, "3d6 wererats"), + (91, 91, "1 assassin"), + (92, 92, "1d3 invisible stalkers"), + (93, 93, "1 gray slaad"), + (94, 94, "1 young silver dragon"), + (95, 95, "1d4 cambions or 1d4 revenants"), + (96, 96, "3d6 wights"), + (97, 97, "1 archmage"), + (98, 98, "2d4 vampire spawn or 1d4 oni"), + (99, 99, "1 mage with 1 shield guardian"), + (100, 100, "1 rakshasa or 1 vampire") + ], + "11-16": [ + (1, 1, "1 mimic"), + (2, 5, "1 bandit captain with 5d10 bandits, all on riding horses"), + (6, 10, "1d10 knights on warhorses (one knight is a doppelganger)"), + (11, 13, "1d8 succubi or 1d8 incubi"), + (14, 16, "3d6 cult fanatics"), + (17, 19, "1d10 wights"), + (20, 22, "3d6 wererats"), + (23, 25, "A distant boom followed by a plume of smoke rising from the other side of the community"), + (26, 28, "1d8 + 1 ghosts"), + (29, 31, "2d10 gargoyles"), + (32, 34, "1d6 + 2 water weirds"), + (35, 37, "1d4 + 4 will-o'-wisps"), + (38, 40, "Street performers putting on a puppet show, involving two puppets beating each other with sticks to the amusement of the gathered crowd"), + (41, 43, "2d4 couatls"), + (44, 46, "2d8 ghasts"), + (47, 51, "1d8 + 1 veterans"), + (52, 55, "3d4 priests"), + (56, 58, "2d4 cambions"), + (59, 61, "1d10 revenants"), + (62, 65, "2d4 phase spiders"), + (66, 69, "A scruffy commoner that ducks into an alley to make a purchase from a suspicious looking figure"), + (70, 72, "1d8 invisible stalkers"), + (73, 75, "1d8 + 1 gladiators"), + (76, 80, "Two farmers trading blows over the price of potatoes (50% chance for one farmer to be a retired assassin)"), + (81, 82, "1d4 young silver dragons"), + (83, 84, "1d4 assassins"), + (85, 86, "1d8 oni"), + (87, 88, "1d4 mages with 1d4 shield guardians"), + (89, 90, "1d10 vampire spawn"), + (91, 92, "1 adult silver dragon"), + (93, 94, "1d4 gray slaadi"), + (95, 96, "1 spellcaster vampire or 1 warrior vampire"), + (97, 97, "1 rakshasa"), + (98, 98, "1 vampire"), + (99, 99, "1 ancient silver dragon"), + (100, 100, "1 archmage speeding down the street on a riding horse, blasting 1d4 guards with spells") + ], + "17-20": [ + (1, 5, "1d10 invisible stalkers"), + (6, 10, "1d10 revenants"), + (11, 14, "1d6 + 2 gladiators"), + (15, 18, "2d4 cambions"), + (19, 22, "2d6 succubi or 2d6 incubi"), + (23, 25, "A witch (archmage) who zooms overhead on a broom of flying"), + (26, 30, "1d4 gray slaadi"), + (31, 35, "2d8 couatls"), + (36, 40, "A distraught parent who rushes up to people, begging for help for a child who fell into the sewer"), + (41, 45, "1d3 young silver dragons"), + (46, 50, "3d6 ghosts"), + (51, 55, "1 adult silver dragon"), + (56, 60, "1d4 mages with 1d4 shield guardians"), + (61, 65, "An aggressive merchant who hawks wares to passersby, claiming to be the purveyor of the finest silks in all the land"), + (66, 70, "1 ancient silver dragon"), + (71, 75, "3d6 vampire spawn"), + (76, 80, "A patrol of 2d10 guards marching up the street, searching for someone or something"), + (81, 85, "1d10 assassins"), + (86, 90, "1d4 + 1 gray slaadi"), + (91, 93, "1d10 oni"), + (94, 96, "1 spellcaster vampire or 1 warrior vampire"), + (97, 97, "1d4 archmages"), + (98, 98, "1d3 rakshasas"), + (99, 99, "1d4 vampires"), + (100, 100, "1 tarrasque") + ] + }, + + "plane_of_air": { + "1-4": [ + (1, 15, "1d4 air elementals (small)"), + (16, 25, "1d6 giant eagles"), + (26, 35, "1d4 griffons"), + (36, 45, "2d6 winged kobolds"), + (46, 55, "1d4 hippogriffs"), + (56, 65, "1d3 manticores"), + (66, 75, "1d8 + 2 aarakocra warriors"), + (76, 85, "1d4 djinn (young)"), + (86, 95, "1d2 young air elementals"), + (96, 100, "1 young air dragon (crystal or blue)"), + ], + "5-10": [ + (1, 15, "1d6 air elementals"), + (16, 25, "2d4 giant eagles"), + (26, 35, "1d4 djinn"), + (36, 45, "1d3 manticores"), + (46, 55, "1d6 + 2 griffons"), + (56, 65, "1d4 aarakocra elders"), + (66, 75, "1d3 air elementals (elder)"), + (76, 85, "1 young air dragon"), + (86, 95, "1d4 djinn nobles"), + (96, 100, "1 roc"), + ], + "11-16": [ + (1, 15, "2d4 air elementals"), + (16, 25, "1d4 djinn nobles"), + (26, 35, "1d3 rocs"), + (36, 45, "1d6 young air dragons"), + (46, 55, "1d4 air elementals (elder)"), + (56, 65, "1d6 djinn"), + (66, 75, "1 adult air dragon"), + (76, 85, "1d3 air elementals (greater)"), + (86, 95, "1d4 djinn sultans"), + (96, 100, "1d2 adult air dragons"), + ], + "17-20": [ + (1, 15, "1d6 air elementals (greater)"), + (16, 25, "1d4 adult air dragons"), + (26, 35, "1d3 rocs (ancient)"), + (36, 45, "1d6 djinn sultans"), + (46, 55, "1 air elemental lord"), + (56, 65, "2d4 air elementals (elder)"), + (66, 75, "1 ancient air dragon"), + (76, 85, "1d4 adult air dragons with 1d6 young"), + (86, 95, "1d3 air elemental lords"), + (96, 100, "1 ancient air dragon with 1d4 djinn nobles"), + ], + }, + "plane_of_earth": { + "1-4": [ + (1, 15, "1d4 earth elementals (small)"), + (16, 25, "2d6 goblins"), + (26, 35, "1d4 + 2 giant beetles"), + (36, 45, "1d8 + 1 dwarven warriors"), + (46, 55, "1d6 + 2 hobgoblins"), + (56, 65, "1d4 ogres"), + (66, 75, "1d3 xorns"), + (76, 85, "1d6 giant ants"), + (86, 95, "1d4 bulettes (young)"), + (96, 100, "1 young earth dragon (or purple wormling)"), + ], + "5-10": [ + (1, 15, "1d6 earth elementals"), + (16, 25, "1d4 xorns"), + (26, 35, "1d6 ogres"), + (36, 45, "1d4 bulettes"), + (46, 55, "2d4 dwarven veterans"), + (56, 65, "1d3 earth elementals (elder)"), + (66, 75, "1d6 giant scorpions"), + (76, 85, "1 young earth dragon"), + (86, 95, "1d4 galeb duhr"), + (96, 100, "1 roc (mountain)"), + ], + "11-16": [ + (1, 15, "2d4 earth elementals"), + (16, 25, "1d4 bulettes (ancient)"), + (26, 35, "1d6 xorns (noble)"), + (36, 45, "1d4 young earth dragons"), + (46, 55, "1d3 earth elementals (elder)"), + (56, 65, "1d6 stone golems"), + (66, 75, "1 adult earth dragon"), + (76, 85, "1d4 galeb duhr (elder)"), + (86, 95, "1d3 dao (earth genies)"), + (96, 100, "1d2 adult earth dragons"), + ], + "17-20": [ + (1, 15, "1d6 earth elementals (greater)"), + (16, 25, "1d4 adult earth dragons"), + (26, 35, "1d6 dao nobles"), + (36, 45, "1 earth elemental lord"), + (46, 55, "1d4 stone golems (ancient)"), + (56, 65, "1d3 ancient earth dragons"), + (66, 75, "1d6 xorns (royal)"), + (76, 85, "1d4 adult earth dragons with 1d4 bulettes"), + (86, 95, "1d3 earth elemental lords"), + (96, 100, "1 ancient earth dragon with 1d4 dao"), + ], + }, + "plane_of_fire": { + "1-4": [ + (1, 15, "1d4 fire elementals (small)"), + (16, 25, "1d6 + 2 fire snakes"), + (26, 35, "1d4 magma mephits"), + (36, 45, "1d4 + 1 azers"), + (46, 55, "1d6 firenewts"), + (56, 65, "1d3 salamanders (young)"), + (66, 75, "1d4 hell hounds"), + (76, 85, "1d2 fire elementals (young)"), + (86, 95, "1d4 magmins"), + (96, 100, "1 young fire dragon (or pyrohydra)"), + ], + "5-10": [ + (1, 15, "1d6 fire elementals"), + (16, 25, "1d4 salamanders"), + (26, 35, "1d6 azers (veterans)"), + (36, 45, "1d4 hell hounds (pack)"), + (46, 55, "1d3 fire elementals (elder)"), + (56, 65, "1d6 firenewts (warlocks)"), + (66, 75, "1d2 young fire dragons"), + (76, 85, "1d4 efreet"), + (86, 95, "1d6 magmins (greater)"), + (96, 100, "1 roc (fire variant)"), + ], + "11-16": [ + (1, 15, "2d4 fire elementals"), + (16, 25, "1d4 efreet nobles"), + (26, 35, "1d6 salamanders (elder)"), + (36, 45, "1d4 young fire dragons"), + (46, 55, "1d3 fire elementals (greater)"), + (56, 65, "1d6 azers (captains)"), + (66, 75, "1 adult fire dragon"), + (76, 85, "1d4 efreet sultans"), + (86, 95, "1d3 fire elementals (elder)"), + (96, 100, "1d2 adult fire dragons"), + ], + "17-20": [ + (1, 15, "1d6 fire elementals (greater)"), + (16, 25, "1d4 adult fire dragons"), + (26, 35, "1d6 efreet sultans"), + (36, 45, "1 fire elemental lord"), + (46, 55, "1d4 salamanders (ancient)"), + (56, 65, "1d3 ancient fire dragons"), + (66, 75, "1d6 azers (commanders)"), + (76, 85, "1d4 adult fire dragons with 1d6 efreet"), + (86, 95, "1d3 fire elemental lords"), + (96, 100, "1 ancient fire dragon with 1d4 efreet nobles"), + ], + }, + "plane_of_water": { + "1-4": [ + (1, 15, "1d4 water elementals (small)"), + (16, 25, "2d6 sahuagin"), + (26, 35, "1d4 + 2 giant octopuses"), + (36, 45, "1d6 merfolk (guards)"), + (46, 55, "1d4 + 1 giant crabs"), + (56, 65, "1d3 merrow"), + (66, 75, "1d4 water weirds"), + (76, 85, "1d6 giant sharks"), + (86, 95, "1d2 young water elementals"), + (96, 100, "1 young water dragon (or sea serpent)"), + ], + "5-10": [ + (1, 15, "1d6 water elementals"), + (16, 25, "1d4 merrow"), + (26, 35, "1d6 sahuagin (priestesses)"), + (36, 45, "1d3 water elementals (elder)"), + (46, 55, "1d4 giant octopuses (elder)"), + (56, 65, "1d2 young water dragons"), + (66, 75, "1d4 marids"), + (76, 85, "1d6 giant sharks (hunter)"), + (86, 95, "1d4 water weirds (greater)"), + (96, 100, "1 roc (sea variant)"), + ], + "11-16": [ + (1, 15, "2d4 water elementals"), + (16, 25, "1d4 marid nobles"), + (26, 35, "1d6 merrow (elders)"), + (36, 45, "1d4 young water dragons"), + (46, 55, "1d3 water elementals (greater)"), + (56, 65, "1d6 sahuagin (barons)"), + (66, 75, "1 adult water dragon"), + (76, 85, "1d4 marid sultans"), + (86, 95, "1d3 water elementals (elder)"), + (96, 100, "1d2 adult water dragons"), + ], + "17-20": [ + (1, 15, "1d6 water elementals (greater)"), + (16, 25, "1d4 adult water dragons"), + (26, 35, "1d6 marid sultans"), + (36, 45, "1 water elemental lord"), + (46, 55, "1d4 giant octopuses (ancient)"), + (56, 65, "1d3 ancient water dragons"), + (66, 75, "1d6 sahuagin (high priestesses)"), + (76, 85, "1d4 adult water dragons with 1d6 marids"), + (86, 95, "1d3 water elemental lords"), + (96, 100, "1 ancient water dragon with 1d4 marid nobles"), + ], + } +} diff --git a/generate_encounter/surprise_rolls_and_reaction.md b/generate_encounter/surprise_rolls_and_reaction.md new file mode 100644 index 0000000..210c884 --- /dev/null +++ b/generate_encounter/surprise_rolls_and_reaction.md @@ -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, и модификатор для взаимодействия реакции, каждый модификатор -с словарь пар строковое значение причина модификатора - целочисленное число, все модификаторы суммируются и дают результат итоговый. что скажешь? также, для броска, для результата при выводе с обработкой промпта и результата скрипта должна агентом прикладыватьться простые таблицы, предварителньо будет сказано, какие модификаторы использованы, какой реузльтата, и елси гм использует другие модификаторы, тот вот уже итоговое значение, его поменяешь , и вот тебе таблицы, что понять, что будет после изменения. Что скажешь? \ No newline at end of file diff --git a/generate_npc/abilities.js b/generate_npc/abilities.js new file mode 100644 index 0000000..83d9999 --- /dev/null +++ b/generate_npc/abilities.js @@ -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 +} \ No newline at end of file diff --git a/generate_npc/abilities.py b/generate_npc/abilities.py new file mode 100644 index 0000000..2724dd5 --- /dev/null +++ b/generate_npc/abilities.py @@ -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)} \ No newline at end of file diff --git a/generate_npc/ages.js b/generate_npc/ages.js new file mode 100644 index 0000000..74051e1 --- /dev/null +++ b/generate_npc/ages.js @@ -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, +}; diff --git a/generate_npc/ages.py b/generate_npc/ages.py new file mode 100644 index 0000000..d06c4ee --- /dev/null +++ b/generate_npc/ages.py @@ -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 \ No newline at end of file diff --git a/generate_npc/backgrounds.js b/generate_npc/backgrounds.js new file mode 100644 index 0000000..7ea66a4 --- /dev/null +++ b/generate_npc/backgrounds.js @@ -0,0 +1,1575 @@ +// https://chatgpt.com/g/g-qgcben3uu-faerun/c/66ef4e4f-d1c4-800b-b20e-3969cb619c25 +// https://dzen.ru/a/ZGxhsBGbTjaBeUhc?ysclid=m1dne1zyr1205412782 + +// TODO: сделать на часть преступных бэкграундов ограничение по мировоззрению +// TODO: увязать расу и выбор дополнительного языка (сделать выбор соответствующего языка от бэкграунда намного вероятнее) + +// По дорогам и рекам Королевств проезжают менестрели и ремесленники, купцы и охранники, солдаты и моряки + +/*d20 Guild Business +1 Alchemists and apothecaries +2 Armorers, locksmiths, and finesmiths +3 Brewers, distillers, and vintners +4 Calligraphers, scribes, and scriveners +5 Carpenters, roofers, and plasterers +6 Cartographers, surveyors, and chart-makers +7 Cobblers and shoemakers +8 Cooks and bakers +9 Glassblowers and glaziers +10 Jewelers and gemcutters +11 Leatherworkers, skinners, and tanners +12 Masons and stonecutters +13 Painters, limners, and sign-makers +14 Potters and tile-makers +15 Shipwrights and sailmakers +16 Smiths and metal-forgers +17 Tinkers, pewterers, and casters +18 Wagon-makers and wheelwrights +19 Weavers and dyers +20 Woodcarvers, coopers, and bowyers*/ + +// Отдельные переменные для каждого инструмента +const alchemistsSupplies = "Alchemist's supplies"; +const brewersSupplies = "Brewer's supplies"; +const calligraphersSupplies = "Calligrapher's supplies"; +const carpentersTools = "Carpenter's tools"; +const cartographersTools = "Cartographer's tools"; +const cobblersTools = "Cobbler's tools"; +const cooksUtensils = "Cook's utensils"; +const glassblowersTools = "Glassblower's tools"; +const jewelersTools = "Jeweler's tools"; +const leatherworkersTools = "Leatherworker's tools"; +const masonsTools = "Mason's tools"; +const paintersTools = "Painter's tools"; +const pottersTools = "Potter's tools"; +const smithsTools = "Smith's tools"; +const tinkersTools = "Tinker's tools"; +const weaversTools = "Weaver's tools"; +const woodcarversTools = "Woodcarver's tools"; + +// Добавил (хоумбрю) +const barberTools = "Barber's Tools"; + +// Массив с объединёнными инструментами ремесленников +const artisansTools = [ + alchemistsSupplies, + brewersSupplies, + calligraphersSupplies, + carpentersTools, + cartographersTools, + cobblersTools, + cooksUtensils, + glassblowersTools, + jewelersTools, + leatherworkersTools, + masonsTools, + paintersTools, + pottersTools, + smithsTools, + tinkersTools, + weaversTools, + woodcarversTools +]; + +// Наборы для игр +const diceSet = "Dice set"; +const dragonchessSet = "Dragonchess set"; +const playingCardSet = "Playing card set"; +const threeDragonAnteSet = "Three-Dragon Ante set"; + +// Наборы для игр массив +const gamingSets = [ + diceSet, + dragonchessSet, + playingCardSet, + threeDragonAnteSet +]; + +// Прочие наборы и инструменты +const disguiseKit = "Disguise kit"; +const forgeryKit = "Forgery kit"; +const herbalismKit = "Herbalism kit"; +const navigatorsTools = "Navigator's tools"; +const poisonersKit = "Poisoner's kit"; +const thievesTools = "Thieves' tools"; + +// Транспорт +const vehiclesLand = "Vehicles (Land)"; +const vehiclesWater = "Vehicles (Water)"; + +const otherTools = [ + disguiseKit, + forgeryKit, + herbalismKit, + navigatorsTools, + poisonersKit, + thievesTools, + vehiclesLand, + vehiclesWater +]; + +// Музыкальные инструменты +const bagpipes = "Bagpipes"; +const drum = "Drum"; +const dulcimer = "Dulcimer"; +const flute = "Flute"; +const lute = "Lute"; +const lyre = "Lyre"; +const horn = "Horn"; +const panFlute = "Pan flute"; +const shawm = "Shawm"; +const viol = "Viol"; + +// Массив музыкальных инструментов +const musicalInstruments = [ + bagpipes, + drum, + dulcimer, + flute, + lute, + lyre, + horn, + panFlute, + shawm, + viol +]; + +const allTools = [...artisansTools, ...gamingSets, ...musicalInstruments, ...otherTools]; + +// +Морские профессии +let maritimeProfessions = { + "Моряк": + { + "skills": [dv.skills.athletics, dv.skills.perception], + "tools": [navigatorsTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 35 + }, + "Рыбак": + { + "skills": [dv.skills.survival, dv.skills.animalHandling], + "tools": ["Fishing tackle"], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 25 + }, + "Пират": + { + "skills": [dv.skills.athletics, dv.skills.perception], + "tools": [vehiclesWater, navigatorsTools], + "languages": [], + "chance": 10 + }, + "Гребец": + { + "skills": [dv.skills.athletics, dv.skills.survival], + "tools": [vehiclesWater], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 10 + }, + "Боцман": + { + "skills": [dv.skills.intimidation, dv.skills.athletics], + "tools": [vehiclesWater, navigatorsTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 5 + }, + "Штурман": + { + "skills": [dv.skills.arcana, dv.skills.perception], + "tools": [navigatorsTools, cartographersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + }, + "Канонир": + { + "skills": [dv.skills.athletics, dv.skills.investigation], + "tools": [smithsTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + }, + "Кок корабля": + { + "skills": [dv.skills.insight, dv.skills.performance], + "tools": [cooksUtensils], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + }, + "Кладовщик корабля": + { + "skills": [dv.skills.investigation, dv.skills.perception], + "tools": [vehiclesWater], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Лоцман": + { + "skills": [dv.skills.perception, dv.skills.survival], + "tools": [navigatorsTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Канатчик": + { + "skills": [dv.skills.athletics, dv.skills.sleightOfHand], + "tools": [weaversTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 1 + }, + "Оружейник корабля": + { + "skills": [dv.skills.athletics, dv.skills.investigation], + "tools": [smithsTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 1 + } +}; + +// +Фермеры, пастухи и охотники +let agrarianProfessions = { + "Фермер": + { + "skills": [dv.skills.animalHandling, dv.skills.nature], + "tools": [brewersSupplies, carpentersTools, cobblersTools, cooksUtensils, leatherworkersTools, masonsTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 25 + }, + "Пастух": + { + "skills": [dv.skills.animalHandling, dv.skills.nature], + "tools": [musicalInstruments], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 15 + }, + "Земледелец": + { + "skills": [dv.skills.athletics, dv.skills.nature], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 10 + }, + "Охотник": + { + "skills": [dv.skills.stealth, dv.skills.survival], + "tools": ["Animal traps"], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 10 + }, + "Овчар": + { + "skills": [dv.skills.animalHandling, dv.skills.survival], + "tools": [barberTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 5 + }, + "Скотник": + { + "skills": [dv.skills.animalHandling, dv.skills.survival], + "tools": [barberTools, leatherworkersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 5 + }, + "Свинопас": + { + "skills": [dv.skills.animalHandling, dv.skills.nature], + "tools": [musicalInstruments], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 5 + }, + "Мельник": + { + "skills": [dv.skills.athletics, dv.skills.investigation], + "tools": [cooksUtensils], // * + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 4 + }, + "Птицевод": + { + "skills": [dv.skills.animalHandling, dv.skills.survival], + "tools": [woodcarversTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + }, + "Заводчик лошадей": + { + "skills": [dv.skills.animalHandling, dv.skills.persuasion], + "tools": [musicalInstruments, calligraphersSupplies], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + }, + "Садовник": + { + "skills": [dv.skills.nature, dv.skills.survival], + "tools": [herbalismKit], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + }, + "Пчеловод": + { + "skills": [dv.skills.animalHandling, dv.skills.survival], + "tools": [woodcarversTools, cooksUtensils], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Сыровар": + { + "skills": [dv.skills.animalHandling, dv.skills.investigation], + "tools": [cooksUtensils], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Винодел": + { + "skills": [dv.skills.nature, dv.skills.persuasion], + "tools": [brewersSupplies], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Молочник": + { + "skills": [dv.skills.animalHandling, dv.skills.athletics], + "tools": [cooksUtensils], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Зверолов": + { + "skills": [dv.skills.stealth, dv.skills.survival], + "tools": ["Animal traps"], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + } +}; + +// +Ремесленники +let artisanProfessions = { + "Кузнец": + { + "skills": [dv.skills.athletics, dv.skills.insight], + "tools": [smithsTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 12 + }, + "Кожевенник": + { + "skills": [dv.skills.survival, dv.skills.investigation], + "tools": [leatherworkersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 9 + }, + "Ткач": + { + "skills": [dv.skills.insight, dv.skills.investigation], + "tools": [weaversTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 9 + }, + "Пекарь": + { + "skills": [dv.skills.insight, dv.skills.investigation], + "tools": [cooksUtensils], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 8 + }, + "Мясник": + { + "skills": [dv.skills.athletics, dv.skills.survival], + "tools": [cooksUtensils], // * + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 8 + }, + "Сапожник": + { + "skills": [dv.skills.investigation, dv.skills.perception], + "tools": [cobblersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 6 + }, + "Повар": + { + "skills": [dv.skills.insight, dv.skills.performance], + "tools": [cooksUtensils], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 7 + }, + "Столяр": + { + "skills": [dv.skills.athletics, dv.skills.perception], + "tools": [carpentersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 5 + }, + "Резчик по дереву": + { + "skills": [dv.skills.athletics, dv.skills.perception], + "tools": [woodcarversTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 5 + }, + "Каменотес": + { + "skills": [dv.skills.athletics, dv.skills.investigation], + "tools": [masonsTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 5 + }, + "Гончар": + { + "skills": [dv.skills.investigation, dv.skills.performance], + "tools": [pottersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 5 + }, + "Булочник": + { + "skills": [dv.skills.insight, dv.skills.performance], + "tools": [cooksUtensils], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 4 + }, + "Бондарь": + { + "skills": [dv.skills.athletics, dv.skills.investigation], + "tools": [woodcarversTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 4 + }, + "Пивовар": + { + "skills": [dv.skills.investigation, dv.skills.insight], + "tools": [brewersSupplies], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 4 + }, + "Кирпичник": + { + "skills": [dv.skills.athletics, dv.skills.investigation], + "tools": [masonsTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + }, + "Кондитер": + { + "skills": [dv.skills.insight, dv.skills.investigation], + "tools": [cooksUtensils], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Изготовитель свечей": + { + "skills": [dv.skills.investigation, dv.skills.insight], + "tools": [cooksUtensils], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Механик": + { + "skills": [dv.skills.investigation, dv.skills.sleightOfHand], + "tools": [tinkersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Стеклодув": + { + "skills": [dv.skills.investigation, dv.skills.perception], + "tools": [glassblowersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 1 + }, + "Часовщик": + { + "skills": [dv.skills.investigation, dv.skills.perception], + "tools": [tinkersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 1 + }, + "Ювелир": + { + "skills": [dv.skills.insight, dv.skills.investigation], + "tools": [jewelersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 1 + }, + "Ткач ковров": + { + "skills": [dv.skills.insight, dv.skills.perception], + "tools": [weaversTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 1 + }, + "Лудильщик": + { + "skills": [dv.skills.survival, dv.skills.investigation], + "tools": [tinkersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 1 + } +}; + +// +Музыканты и артисты +let performingArtsProfessions = { + "Музыкант": + { + "skills": [dv.skills.performance, dv.skills.perception], + "tools": [musicalInstruments], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 21 + }, + "Актер театра": + { + "skills": [dv.skills.performance, dv.skills.deception], + "tools": [musicalInstruments, weaversTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 15 + }, + "Певец": + { + "skills": [dv.skills.performance, dv.skills.persuasion], + "tools": [], // * чтобы знал пару языков, на которых песни + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 15 + }, + "Танцор": + { + "skills": [dv.skills.acrobatics, dv.skills.performance], + "tools": [], // * чтобы знал пару языков, на которых песни + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 10 + }, + "Художник": + { + "skills": [dv.skills.performance, dv.skills.investigation], + "tools": [paintersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 6 + }, + "Поэт": + { + "skills": [dv.skills.performance, dv.skills.persuasion], + "tools": [], // * чтобы знал пару языков, на которых песни + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 6 + }, + "Жонглер": + { + "skills": [dv.skills.sleightOfHand, dv.skills.performance], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 4 + }, + "Фокусник": + { + "skills": [dv.skills.sleightOfHand, dv.skills.performance], + "tools": [woodcarversTools], // * + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 4 + }, + "Кукловод": + { + "skills": [dv.skills.performance, dv.skills.sleightOfHand], + "tools": [weaversTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + }, + "Скульптор": + { + "skills": [dv.skills.perception, dv.skills.performance], + "tools": [paintersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + }, + "Бард": + { + "skills": [dv.skills.performance, dv.skills.history], + "tools": [musicalInstruments], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + }, + "Оркестровый музыкант": + { + "skills": [dv.skills.performance, dv.skills.insight], + "tools": [musicalInstruments], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Трубач": + { + "skills": [dv.skills.performance, dv.skills.athletics], + "tools": [horn], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Лютнист": + { + "skills": [dv.skills.performance, dv.skills.perception], + "tools": [lute], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Гармонист": + { + "skills": [dv.skills.performance, dv.skills.sleightOfHand], + "tools": ["Harmonica"], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Писатель (драматург)": + { + "skills": [dv.skills.history, dv.skills.performance], + "tools": [calligraphersSupplies], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + } +}; + +// +Военные и стражники +let militaryProfessions = { + "Легкий пехотинец": + { + "skills": [dv.skills.athletics, dv.skills.perception], + "tools": [gamingSets], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 15 + }, + "Тяжелый пехотинец": + { + "skills": [dv.skills.athletics, dv.skills.intimidation], + "tools": [gamingSets], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 10 + }, + "Стражник": + { + "skills": [dv.skills.athletics, dv.skills.perception], + "tools": [gamingSets], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 10 + }, + "Ополченец": + { + "skills": [dv.skills.athletics, dv.skills.perception], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 10 + }, + "Лучник": + { + "skills": [dv.skills.perception, dv.skills.survival], + "tools": [gamingSets], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 10 + }, + "Копейщик": + { + "skills": [dv.skills.athletics, dv.skills.survival], + "tools": [gamingSets], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 8 + }, + "Кавалерист": + { + "skills": [dv.skills.athletics, dv.skills.animalHandling], + "tools": [gamingSets], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 5 + }, + "Гарнизонный солдат": + { + "skills": [dv.skills.athletics, dv.skills.insight], + "tools": [gamingSets], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 5 + }, + "Наемник": + { + "skills": [dv.skills.athletics, dv.skills.survival], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 5 + }, + "Рекрут": + { + "skills": [dv.skills.athletics, dv.skills.survival], + "tools": [gamingSets], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 4 + }, + "Телохранитель": + { + "skills": [dv.skills.athletics, dv.skills.insight], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + }, + "Разведчик": + { + "skills": [dv.skills.stealth, dv.skills.survival], + "tools": [cartographersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + }, + "Оруженосец": + { + "skills": [dv.skills.athletics, dv.skills.perception], + "tools": [smithsTools, leatherworkersTools, weaversTools, gamingSets], // * + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + }, + "Баллистик": + { + "skills": [dv.skills.investigation, dv.skills.athletics], + "tools": [smithsTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Фортификатор": + { + "skills": [dv.skills.investigation, dv.skills.perception], + "tools": [masonsTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Гладиатор": + { + "skills": [dv.skills.athletics, dv.skills.intimidation], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Осадный инженер": + { + "skills": [dv.skills.investigation, dv.skills.survival], + "tools": [carpentersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Морской пехотинец": + { + "skills": [dv.skills.athletics, dv.skills.perception], + "tools": [navigatorsTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Катапультир": + { + "skills": [dv.skills.athletics, dv.skills.survival], + "tools": [masonsTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Шпион": + { + "skills": [dv.skills.stealth, dv.skills.deception], + "tools": [disguiseKit, thievesTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 1 + } +}; + +// +Профессии воров и мошенников +let criminalProfessions = { + "Карманник": + { + "skills": [dv.skills.sleightOfHand, dv.skills.stealth], + "tools": [thievesTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 20 + }, + "Мошенник": + { + "skills": [dv.skills.deception, dv.skills.persuasion], + "tools": [gamingSets], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 15 + }, + "Домушник": + { + "skills": [dv.skills.stealth, dv.skills.perception], + "tools": [thievesTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 15 + }, + "Контрабандист": + { + "skills": [dv.skills.stealth, dv.skills.deception], + "tools": [vehiclesLand, vehiclesWater, thievesTools, disguiseKit, forgeryKit], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 10 + }, + "Карточный шулер": + { + "skills": [dv.skills.sleightOfHand, dv.skills.deception], + "tools": [playingCardSet], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 8 + }, + "Скупщик краденого": + { + "skills": [dv.skills.insight, dv.skills.persuasion], + "tools": [disguiseKit], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 7 + }, + "Медвежатник": + { + "skills": [dv.skills.sleightOfHand, dv.skills.stealth], + "tools": [thievesTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 5 + }, + "Скотокрад": + { + "skills": [dv.skills.stealth, dv.skills.survival], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 5 + }, + "Грабитель повозок": + { + "skills": [dv.skills.athletics, dv.skills.stealth], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 4 + }, + "Угонщик повозок": + { + "skills": [dv.skills.stealth, dv.skills.sleightOfHand], + "tools": [vehiclesLand], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + }, + "Фальшивомонетчик": + { + "skills": [dv.skills.deception, dv.skills.investigation], + "tools": [forgeryKit], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + }, + "Рекетир": + { + "skills": [dv.skills.intimidation, dv.skills.athletics], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Подделыватель документов": + { + "skills": [dv.skills.investigation, dv.skills.sleightOfHand], + "tools": [forgeryKit], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Похититель скота": + { + "skills": [dv.skills.survival, dv.skills.stealth], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Ассасин": + { + "skills": [dv.skills.stealth, dv.skills.deception], + "tools": [poisonersKit, disguiseKit, forgeryKit], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 1 + }, + "Наёмный убийца": + { + "skills": [dv.skills.stealth, dv.skills.intimidation], + "tools": [poisonersKit, disguiseKit, forgeryKit], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 1 + }, + "Поджигатель": + { + "skills": [dv.skills.stealth, dv.skills.intimidation], + "tools": [alchemistsSupplies], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 1 + }, + "Похититель": + { + "skills": [dv.skills.stealth, dv.skills.deception], + "tools": [disguiseKit, thievesTools, forgeryKit], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 1 + } +}; + +// +Интеллектуалы и ученые +let academicProfessions = { + "Писарь": + { + "skills": [dv.skills.history, dv.skills.investigation], + "tools": [calligraphersSupplies], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 15 + }, + "Учитель": + { + "skills": [dv.skills.persuasion, dv.skills.insight], + "tools": [allTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 15 + }, + "Аколит": + { + "skills": [dv.skills.religion, dv.skills.insight], + "tools": [calligraphersSupplies], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 12 + }, + "Ученый": + { + "skills": [dv.skills.history, dv.skills.arcana], + "tools": [calligraphersSupplies, cartographersTools, alchemistsSupplies], // * + "languages": [dv.languages.livingLanguagesExceptCommon, dv.languages.deadLanguages], + "chance": 9 + }, + "Алхимик": + { + "skills": [dv.skills.arcana, dv.skills.investigation], + "tools": [alchemistsSupplies], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 8 + }, + "Инженер": + { + "skills": [dv.skills.investigation, dv.skills.perception], + "tools": [tinkersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 8 + }, + "Профессор": + { + "skills": [dv.skills.history, dv.skills.persuasion], + "tools": [], // * + "languages": [dv.languages.livingLanguagesExceptCommon, dv.languages.deadLanguages], + "chance": 7 + }, + "Историк": + { + "skills": [dv.skills.history, dv.skills.insight], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon, dv.languages.deadLanguages], + "chance": 4 + }, + "Библиотекарь": + { + "skills": [dv.skills.history, dv.skills.investigation], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon, dv.languages.deadLanguages], + "chance": 4 + }, + "Картограф": + { + "skills": [dv.skills.investigation, dv.skills.perception], + "tools": [cartographersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + }, + "Археолог": + { + "skills": [dv.skills.history, dv.skills.investigation], + "tools": [cartographersTools], + "languages": [dv.languages.livingLanguagesExceptCommon, dv.languages.deadLanguages], + "chance": 3 + }, + "Лингвист": + { + "skills": [dv.skills.history, dv.skills.insight], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon, dv.languages.deadLanguages], + "chance": 2 + }, + "Математик": + { + "skills": [dv.skills.investigation, dv.skills.insight], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Философ": + { + "skills": [dv.skills.religion, dv.skills.insight], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Исследователь": + { + "skills": [dv.skills.nature, dv.skills.survival], + "tools": [herbalismKit], + "languages": [dv.languages.livingLanguagesExceptCommon, dv.languages.deadLanguages], + "chance": 2 + }, + "Географ": + { + "skills": [dv.skills.nature, dv.skills.history], + "tools": [cartographersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Астроном": + { + "skills": [dv.skills.arcana, dv.skills.perception], + "tools": [navigatorsTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + } +}; + +// +Городские профессии +let urbanProfessions = { + "Извозчик": + { + "skills": [dv.skills.animalHandling, dv.skills.perception], + "tools": [vehiclesLand], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 20 + }, + "Грузчик": + { + "skills": [dv.skills.athletics, dv.skills.survival], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 17 + }, + /*"Уборщик улиц (дворник)": + { + "skills": [dv.skills.athletics, dv.skills.survival], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 15 + },*/ + "Цирюльник": + { + "skills": [dv.skills.medicine, dv.skills.performance], + "tools": [barberTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 10 + }, + "Трактирщик": + { + "skills": [dv.skills.persuasion, dv.skills.insight], + "tools": [brewersSupplies], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 10 + }, + "Гонец": + { + "skills": [dv.skills.acrobatics, dv.skills.athletics], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 8 + }, + "Фонарщик": + { + "skills": [dv.skills.athletics, dv.skills.perception], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 5 + }, + "Почтальон": + { + "skills": [dv.skills.athletics, dv.skills.perception], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 5 + }, + "Пожарный": + { + "skills": [dv.skills.athletics, dv.skills.perception], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 4 + }, + "Тюремщик": + { + "skills": [dv.skills.intimidation, dv.skills.insight], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + }, + "Садовник городской": + { + "skills": [dv.skills.nature, dv.skills.survival], + "tools": [herbalismKit], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + } +}; + +// +Торговые профессии +let mercantileProfessions = { + "Продавец фруктов и овощей": + { + "skills": [dv.skills.nature, dv.skills.persuasion], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 15 + }, + "Продавец тканей": + { + "skills": [dv.skills.insight, dv.skills.performance], + "tools": [weaversTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 12 + }, + "Продавец лошадей": + { + "skills": [dv.skills.animalHandling, dv.skills.persuasion], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 10 + }, + "Караванщик": + { + "skills": [dv.skills.animalHandling, dv.skills.perception], + "tools": [vehiclesLand], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 10 + }, + "Продавец оружия": + { + "skills": [dv.skills.persuasion, dv.skills.investigation], + "tools": [smithsTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 10 + }, + "Продавец ювелирных изделий": + { + "skills": [dv.skills.persuasion, dv.skills.insight], + "tools": [jewelersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 8 + }, + "Продавец вина": + { + "skills": [dv.skills.persuasion, dv.skills.insight], + "tools": [brewersSupplies], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 8 + }, + "Продавец мебели": + { + "skills": [dv.skills.persuasion, dv.skills.investigation], + "tools": [carpentersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 7 + }, + "Аптекарь": + { + "skills": [dv.skills.medicine, dv.skills.arcana], + "tools": [herbalismKit], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 6 + }, + "Продавец книг": + { + "skills": [dv.skills.history, dv.skills.investigation], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 5 + }, + "Продавец рыбы": + { + "skills": [dv.skills.survival, dv.skills.athletics], + "tools": ["Fishing tackle"], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 5 + }, + "Торговец кожаными изделиями": + { + "skills": [dv.skills.investigation, dv.skills.insight], + "tools": [leatherworkersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 4 + }, + "Торговец тканями": + { + "skills": [dv.skills.insight, dv.skills.persuasion], + "tools": [weaversTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 3 + }, + "Караванный охранник": + { + "skills": [dv.skills.athletics, dv.skills.perception], + "tools": [gamingSets], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Гильдейский казначей": + { + "skills": [dv.skills.investigation, dv.skills.insight], + "tools": [calligraphersSupplies], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 1 + }, + "Продавец пряностей": + { + "skills": [dv.skills.insight, dv.skills.persuasion], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 1 + }, +}; + +// +Строители +let constructionProfessions = { + "Строитель": + { + "skills": [dv.skills.athletics, dv.skills.perception], + "tools": [carpentersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 42 + }, + "Каменщик": + { + "skills": [dv.skills.athletics, dv.skills.investigation], + "tools": [masonsTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 25 + }, + "Архитектор": + { + "skills": [dv.skills.investigation, dv.skills.insight], + "tools": [masonsTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 12 + }, + "Маляр": + { + "skills": [dv.skills.investigation, dv.skills.performance], + "tools": [paintersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 4 + }, + "Плотник": + { + "skills": [dv.skills.athletics, dv.skills.perception], + "tools": [carpentersTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 9 + }, + "Кровельщик": + { + "skills": [dv.skills.athletics, dv.skills.perception], + "tools": ["roofersTools"], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 2 + }, + "Реставратор": + { + "skills": [dv.skills.insight, dv.skills.investigation], + "tools": ["restorationTools"], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 6 + } +}; + +// Медицинские профессии +let medicalProfessions = { + "Врач": + { + "skills": [dv.skills.medicine, dv.skills.perception], + "tools": ["Medical instruments"], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 52 + }, + "Травник": + { + "skills": [dv.skills.nature, dv.skills.medicine], + "tools": [herbalismKit], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 21 + }, + "Хирург": + { + "skills": [dv.skills.medicine, dv.skills.investigation], + "tools": ["Healer's kit"], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 14 + }, + "Акушер": + { + "skills": [dv.skills.medicine, dv.skills.insight], + "tools": ["Medical instruments"], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 8 + }, + "Корабельный врач": + { + "skills": [dv.skills.medicine, dv.skills.perception], + "tools": ["Medical instruments"], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 5 + }, +}; + +// +Религиозные профессии +let religiousProfessions = { + "Священник": + { + "skills": [dv.skills.religion, dv.skills.persuasion], + "tools": [calligraphersSupplies], + "languages": [dv.languages.livingLanguagesExceptCommon, dv.languages.deadLanguages], + "prerequisiteClasses": [dv.classes.cleric, dv.classes.paladin], + "chance": 30 + }, + "Аколит": + { + "skills": [dv.skills.religion, dv.skills.insight], + "tools": [], + "languages": [dv.languages.livingLanguagesExceptCommon, dv.languages.deadLanguages], + "prerequisiteClasses": [dv.classes.cleric, dv.classes.paladin], + "chance": 20 + }, + "Монах": + { + "skills": [dv.skills.religion, dv.skills.acrobatics], + "tools": [herbalismKit], + "languages": [dv.languages.livingLanguagesExceptCommon, dv.languages.deadLanguages], + "prerequisiteClasses": [dv.classes.cleric, dv.classes.paladin], + "chance": 15 + }, + "Храмовый целитель": + { + "skills": [dv.skills.medicine, dv.skills.religion], + "tools": ["Healer's kit"], + "languages": [dv.languages.livingLanguagesExceptCommon], + "prerequisiteClasses": [dv.classes.cleric, dv.classes.paladin], + "chance": 15 + }, + "Певчий в храме": + { + "skills": [dv.skills.performance, dv.skills.religion], + "tools": [musicalInstruments], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 9 + }, + "Церковный учитель": + { + "skills": [dv.skills.religion, dv.skills.persuasion], + "tools": [allTools], + "languages": [dv.languages.livingLanguagesExceptCommon], + "prerequisiteClasses": [dv.classes.cleric, dv.classes.paladin], + "chance": 7 + }, + "Церковный писарь": + { + "skills": [dv.skills.history, dv.skills.investigation], + "tools": [calligraphersSupplies], + "languages": [dv.languages.livingLanguagesExceptCommon], + "chance": 4 + } +}; + +let backgrounds = { + agrarianProfessions: + { + list: agrarianProfessions, + chance: 30.00 // Большая часть населения Фаэруна занята в сельском хозяйстве (40), но уменьшим процент для авантюристов. + }, + militaryProfessions: + { + list: militaryProfessions, + chance: 20.00 // Частые конфликты делают военные профессии значимыми. + }, + maritimeProfessions: + { + list: maritimeProfessions, + chance: 6.00 // Побережье Мечей активно связано с морем, но не вся территория Фаэруна прибрежная. + }, + artisanProfessions: + { + list: artisanProfessions, + chance: 10.00 // Ремесленники играют важную роль в городах и селах. + }, + urbanProfessions: + { + list: urbanProfessions, + chance: 8.00 // Городские профессии распространены, но составляют меньшую часть населения. + }, + criminalProfessions: + { + list: criminalProfessions, + chance: 6.00 // Преступные элементы присутствуют, но не доминируют в обществе (3), но среди авантюристов их будет больше. + }, + mercantileProfessions: + { + list: mercantileProfessions, + chance: 5.00 // Торговля важна для экономики, особенно в городах и на торговых путях. + }, + religiousProfessions: + { + list: religiousProfessions, + chance: 5.00 // Религия играет значимую роль, и многие служат в храмах и святилищах. + }, + medicalProfessions: + { + list: medicalProfessions, + chance: 3.00 // Целители и лекари необходимы, но их число невелико (2), но среди авантюристов чуть больше. + }, + constructionProfessions: + { + list: constructionProfessions, + chance: 3.00 // Строители и каменщики востребованы, но их число ограничено. + }, + academicProfessions: + { + list: academicProfessions, + chance: 2.00 // Академики и учёные редки и сконцентрированы в определённых центрах знаний. + }, + performingArtsProfessions: + { + list: performingArtsProfessions, + chance: 2.00 // Артисты и музыканты встречаются, но составляют небольшую часть населения. + } +}; + + +// get random background + +function getClassSkillsWithBackground(randomClass, background) { + // Берем скиллы из бекграунда + let backgroundSkills = background.skills; + + // Получаем список скиллов для класса персонажа + let skillsChoice = dv.skills.characterClassesSkills[randomClass.toLowerCase()]; + let skillsList = skillsChoice[0]; // список доступных скиллов класса + let skillsNr = skillsChoice[1]; // количество скиллов, которые нужно выбрать + + // Убираем из списка скиллов класса те, что уже выбраны через бекграунд + let filteredSkillsList = removeBackgroundSkillsFromClassSkills(skillsList, backgroundSkills); + + // Выбираем случайные скиллы для класса из оставшегося списка + let randomSkills = dv.skills.getRandomSkills(filteredSkillsList, skillsNr); + + return randomSkills; +} + +function removeBackgroundSkillsFromClassSkills(classSkills, backgroundSkills) { + // Клонируем список скиллов класса + let clonedClassSkills = [...classSkills]; + + // Удаляем скиллы, выбранные через бекграунд + backgroundSkills.forEach(skill => { + const index = clonedClassSkills.indexOf(skill); + if (index > -1) { + clonedClassSkills.splice(index, 1); + } + }); + + return clonedClassSkills; +} + +function getBackground(characterClass, knownLanguagesFromRace) { + while (true) { + // Шаг 1: выбираем случайную группу бекграундов с учетом шанса + const backgroundGroups = Object.keys(backgrounds).map(key => ({ + name: key, + list: backgrounds[key].list, + chance: backgrounds[key].chance + })); + + const selectedGroup = getRandomItemWithChance(backgroundGroups); + + // Шаг 2: выбираем случайную профессию внутри группы с учетом шанса + const professions = Object.keys(selectedGroup.list).map(key => ({ + name: key, + ...selectedGroup.list[key] + })); + + const selectedProfession = getRandomItemWithChance(professions); + + // Шаг 3: проверяем prerequisiteClasses, если есть + if (selectedProfession.prerequisiteClasses && selectedProfession.prerequisiteClasses.length > 0) { + if (!selectedProfession.prerequisiteClasses.includes(characterClass)) { + continue; // Класс не подходит, повторяем выбор + } + } + + // Шаг 4: выбираем элементы для skills, tools, languages + let selectedSkills = selectRandomElements(selectedProfession.skills, 2); + let selectedToolsAndLanguages = selectFromTwoArrays( + selectedProfession.tools, + selectedProfession.languages, + knownLanguagesFromRace // Передаём известные языки для исключения + ); + + // Шаг 5: формируем итоговый блок и возвращаем его + return { + name: selectedProfession.name, + skills: selectedSkills, + tools: selectedToolsAndLanguages.tools, + languages: selectedToolsAndLanguages.languages + }; + } +} + +function getRandomItemWithChance(items) { + const totalChance = items.reduce((sum, item) => sum + item.chance, 0); + const randomValue = Math.random() * totalChance; + let cumulativeChance = 0; + + for (const item of items) { + cumulativeChance += item.chance; + if (randomValue <= cumulativeChance) { + return item; + } + } + return null; // на случай если что-то пойдет не так +} + +function selectRandomElements(array, count, excludeList = []) { + if (!array || array.length === 0) { + return []; + } + + const flattenedArray = array.flat(); + const uniqueElements = [...new Set(flattenedArray)]; + + // Убираем исключённые элементы + const availableElements = uniqueElements.filter(el => !excludeList.includes(el)); + + // Если после исключения элементов ничего не осталось, возвращаем пустой массив + if (availableElements.length === 0) { + return []; + } + + return shuffleArray(availableElements).slice(0, Math.min(count, availableElements.length)); +} + +function selectFromTwoArrays(array1, array2, excludeLanguages = []) { + let selectedFromArray1 = []; + let selectedFromArray2 = []; + + if (array1 && array1.length > 0 && array2 && array2.length > 0) { + selectedFromArray1 = selectRandomElements(array1, 1); + selectedFromArray2 = selectRandomElements(array2, 1, excludeLanguages); + } else if (array1 && array1.length > 0) { + selectedFromArray1 = selectRandomElements(array1, 2); + } else if (array2 && array2.length > 0) { + selectedFromArray2 = selectRandomElements(array2, 2, excludeLanguages); + } + + return { + tools: selectedFromArray1, + languages: selectedFromArray2 + }; +} + +function shuffleArray(array) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; +} + + +dv.backgrounds = { + getClassSkillsWithBackground, + getBackground +} \ No newline at end of file diff --git a/generate_npc/backgrounds.py b/generate_npc/backgrounds.py new file mode 100644 index 0000000..8977a4c --- /dev/null +++ b/generate_npc/backgrounds.py @@ -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) \ No newline at end of file diff --git a/generate_npc/bio_tables.py b/generate_npc/bio_tables.py new file mode 100644 index 0000000..c0ea110 --- /dev/null +++ b/generate_npc/bio_tables.py @@ -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"), # 3–7 на 2d6 + (41.67, "older"), # 8–12 на 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}." + ) \ No newline at end of file diff --git a/generate_npc/classes.js b/generate_npc/classes.js new file mode 100644 index 0000000..8265eb5 --- /dev/null +++ b/generate_npc/classes.js @@ -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 +} \ No newline at end of file diff --git a/generate_npc/classes.py b/generate_npc/classes.py new file mode 100644 index 0000000..46a0331 --- /dev/null +++ b/generate_npc/classes.py @@ -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] diff --git a/generate_npc/generate_npc.js b/generate_npc/generate_npc.js new file mode 100644 index 0000000..f0a7fac --- /dev/null +++ b/generate_npc/generate_npc.js @@ -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); diff --git a/generate_npc/generate_npc.py b/generate_npc/generate_npc.py new file mode 100644 index 0000000..ade6383 --- /dev/null +++ b/generate_npc/generate_npc.py @@ -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.0–1.0), default 0.90 + # необязательные поля для управления количеством + "count": 5, # сколько NPC сгенерировать (default 1) + "level": 3, # уровень NPC (default 1) + } + +Возвращает строку в формате Markdown. +""" + +import random +import math +from typing import Any, Optional +from typing_extensions import TypedDict + +import norminv +import languages as lang +from ages import AGES, get_age_and_modify_abilities +from abilities import get_ability_scores +from classes import ( + CLASSES, CLASSES_CHANCE, COMMONER, + DEFAULT_COMMONER_CHANCE, get_random_class, +) +from races import RACES, select_random_race_and_subrace, get_name, get_languages +from backgrounds import get_background, get_class_skills_with_background +from psycho_types import get_psychosocial_type_and_archetype + +from bio_tables import generate_bio + +ALIGNMENTS = ["LG", "LN", "LE", "NG", "TN", "NE", "CG", "CN", "CE"] +AGE_ORDER = list(AGES.keys()) # ["Youth", "Adult", "MiddleAged", "Old", "Ancient"] + + +# ────────────────────────────────────────────────────────────────────────────── +# Нормировка вероятностей +# ────────────────────────────────────────────────────────────────────────────── + +def _normalize_chances(pool: dict[str, float]) -> dict[str, float]: + """ + Принимает {name: chance, ...}, возвращает нормированный словарь так, + что сумма вероятностей == 100.0. + + Пример: A=3%, B=4% из 10 возможных. + Доступны только A и B → их сумма = 7% → нормируем к 100%: + A = 3/7*100 ≈ 42.86%, B = 4/7*100 ≈ 57.14% + """ + total = sum(pool.values()) + if total == 0: + # Равномерное распределение, если шансы нулевые + n = len(pool) + return {k: 100.0 / n for k in pool} if n else {} + return {k: v / total * 100.0 for k, v in pool.items()} + + +def _weighted_pick(normalized: dict[str, float]) -> str: + """Выбирает ключ из нормированного словаря {name: chance_0_100}.""" + r = random.random() * 100.0 + cumulative = 0.0 + for name, chance in normalized.items(): + cumulative += chance + if r <= cumulative: + return name + return list(normalized.keys())[-1] + + +# ────────────────────────────────────────────────────────────────────────────── +# Фильтрация пулов с последующей нормировкой +# ────────────────────────────────────────────────────────────────────────────── + +def _filtered_race_chances(allowed_races: list[str]) -> dict[str, float]: + """Возвращает нормированный пул рас из списка allowed_races.""" + if not allowed_races: + pool = {r: d["chance"] for r, d in RACES.items()} + else: + # Проверяем каждую разрешенную расу. Если её нет в базе — берем Human. + processed_allowed = [] + for r in allowed_races: + if r in RACES: + processed_allowed.append(r) + else: + processed_allowed.append("Human") + + pool = {r: RACES[r]["chance"] for r in processed_allowed if r in RACES} + return _normalize_chances(pool) + + +def _filtered_age_chances(min_age: str | None, max_age: str | None) -> dict[str, float]: + """ + Возвращает нормированный пул возрастных категорий + в диапазоне [min_age, max_age] по порядку в AGE_ORDER. + """ + min_idx = AGE_ORDER.index(min_age) if min_age and min_age in AGE_ORDER else 0 + max_idx = AGE_ORDER.index(max_age) if max_age and max_age in AGE_ORDER else len(AGE_ORDER) - 1 + allowed = AGE_ORDER[min_idx : max_idx + 1] + pool = {age: AGES[age]["chance"] for age in allowed} + return _normalize_chances(pool) + + +def _filtered_language_chances(allowed_extra_langs: list[str]) -> dict[str, float]: + """ + Возвращает нормированный пул живых языков (без Common), + ограниченный allowed_extra_langs (если задан). + """ + all_lang_pool = {l["name"]: l["chance"] for l in lang.living_languages_except_common} + if not allowed_extra_langs: + return _normalize_chances(all_lang_pool) + pool = {name: chance + for name, chance in all_lang_pool.items() + if name in allowed_extra_langs} + return _normalize_chances(pool) + + +# ────────────────────────────────────────────────────────────────────────────── +# Генерация одного NPC +# ────────────────────────────────────────────────────────────────────────────── + +def _generate_single_npc( + level: int, + race_chances: dict[str, float], + age_chances: dict[str, float], + allowed_classes: list[str], + commoner_chance: float, + lang_chances: dict[str, float], + gender_param: str = "Any", +) -> str: + """Генерирует одного NPC и возвращает строку описания.""" + + # Пол + if gender_param == "M": + gender = "M" + elif gender_param == "F": + gender = "F" + else: + gender = "M" if random.random() < 0.68 else "F" + + # Раса + race_name = _weighted_pick(race_chances) + race_data = RACES[race_name] + subrace_name = None + if "subraces" in race_data and race_data["subraces"]: + sub_pool = _normalize_chances( + {s: d["chance"] for s, d in race_data["subraces"].items()} + ) + subrace_name = _weighted_pick(sub_pool) + race_obj = {"race": race_name, "subrace": subrace_name} + + # Имя + full_name_obj = get_name(race_obj, gender) + full_name = full_name_obj.get("name", "") + + # Языки расы + known_race_languages = get_languages(race_obj, full_name_obj) + known_lang_names = [l["name"] for l in known_race_languages if isinstance(l, dict)] + + # Класс — двухуровневый выбор: Commoner vs боевые классы + char_class = get_random_class(allowed_classes, commoner_chance) + + # Характеристики + ability_scores = get_ability_scores(char_class) + + # Возраст + age_key = _weighted_pick(age_chances) + age_data = AGES[age_key] + aged_scores = dict(ability_scores) + for stat, delta in age_data["modifiers"].items(): + aged_scores[stat] = aged_scores.get(stat, 0) + delta + + # Бэкграунд (с учётом нормированного пула дополнительных языков) + # Передаём уже известные языки расы для исключения дублей + background = _get_background_with_filtered_languages( + char_class, known_lang_names, lang_chances + ) + + # Скиллы класса + class_skills = get_class_skills_with_background(char_class, background) + + # Мировоззрение + alignment = random.choice(ALIGNMENTS) + + # Психотип + psycho = get_psychosocial_type_and_archetype() + + # Bio (XGE tables) + bio = generate_bio(age_key) + + # ── Форматирование строки ────────────────────────────────────────────── + + race_str = f"**{subrace_name} {race_name}**" if subrace_name else f"**{race_name}**" + race_str += " (" + _format_languages(known_race_languages) + ")" + + age_str = f"**{age_key}** ({age_data['explanation']})" + + attrs = ( + f"Str {aged_scores['Strength']}, Dex {aged_scores['Dexterity']}, " + f"Con {aged_scores['Constitution']}, Int {aged_scores['Intelligence']}, " + f"Wis {aged_scores['Wisdom']}, Cha {aged_scores['Charisma']}" + ) + + bg_langs_str = _format_languages(background["languages"]) + bg_str = ( + f"{background['name']} (" + f"{', '.join(background['skills'])}, " + f"{', '.join(background['tools'])}, " + f"{bg_langs_str})" + ) + + psycho_str = ( + f"temperament: {psycho['temperament']['name']}, " + f"psychosocial type: {psycho['psychosocialType']['name']}, " + f"archetype by C.Jung: {psycho['archetype']['name']}" + ) + + return ( + f"**{char_class}** lvl {level}; {gender}; {full_name}; " + f"race: {race_str}; age: {age_str}; {attrs}; al {alignment}; " + f"class skills: {', '.join(class_skills)}; background: {bg_str}; {psycho_str}. {bio}" + ) + + +def _format_languages(languages) -> str: + """Форматирует список языков.""" + parts = [] + for l in languages: + if isinstance(l, dict): + s = f"{l['name']} [{l['alphabet']}" + if l.get("speakers"): + s += f"; {l['speakers']}" + s += "]" + parts.append(s) + else: + parts.append(str(l)) + return ", ".join(parts) + + +def _get_background_with_filtered_languages( + char_class: str, + known_lang_names: list[str], + lang_chances: dict[str, float], +) -> dict: + """ + Аналог get_background(), но при выборе языка бэкграунда + использует нормированный пул lang_chances (ограниченный список). + """ + from backgrounds import ( + BACKGROUNDS, _get_random_item_with_chance, + _flatten, _select_random_elements, + ) + + while True: + group_items = [{"name": k, **v} for k, v in BACKGROUNDS.items()] + selected_group = _get_random_item_with_chance(group_items) + prof_items = [{"name": k, **v} for k, v in selected_group["list"].items()] + prof = _get_random_item_with_chance(prof_items) + + prereqs = prof.get("prerequisite_classes", []) + if prereqs and char_class not in prereqs: + continue + + selected_skills = _select_random_elements(prof["skills"], 2) + + # Инструменты + tools = _select_random_elements(prof.get("tools", []), 1) if prof.get("tools") else [] + + # Языки: выбираем из нормированного (ограниченного) пула, + # исключая уже известные расовые + extra_lang: list = [] + if lang_chances: + available_pool = {k: v for k, v in lang_chances.items() if k not in known_lang_names} + if available_pool: + renormed = _normalize_chances(available_pool) + chosen_name = _weighted_pick(renormed) + lang_obj = next( + (l for l in lang.living_languages_except_common + if l["name"] == chosen_name), + None, + ) + if lang_obj: + extra_lang = [lang_obj] + + return { + "name": prof["name"], + "skills": selected_skills, + "tools": tools, + "languages": extra_lang, + } + + +# ────────────────────────────────────────────────────────────────────────────── +# Публичный метод тула +# ────────────────────────────────────────────────────────────────────────────── + + +class HenchmanConstraint(TypedDict, total=False): + """ + Параметры генерации одной группы NPC. + + Поля: + gender: M, F, Any — по умолчанию Any + + allowed_races: Список разрешённых рас. + Допустимы: Aasimar, Bugbear, Dragonborn, Dwarf, Elf, Firbolg, + Genasi, Gith, Gnome, Goblin, Goliath, Half-Elf, Half-Orc, + Halfling, Hobgoblin, Human, Kenku, Kobold, Leonin, + Lizardfolk, Orc, Satyr, Tabaxi, Tiefling, Triton, Yuan-ti Pureblood. + Пустой список = все расы. + + allowed_classes: Список разрешённых классов. + Допустимы: Bard, Barbarian, Fighter, Wizard, Druid, Cleric, + Warlock, Monk, Paladin, Rogue, Ranger, Sorcerer, Commoner. + Пустой список = все классы (включая Commoner). + + commoner_chance: Вероятность того, что NPC окажется Commoner (0.0–1.0). + Применяется только если Commoner входит в допустимые классы. + По умолчанию 0.90. + + min_age: Минимальный возраст. + Допустимы: Youth, Adult, MiddleAged, Old, Ancient. + + max_age: Максимальный возраст. + Допустимы: Youth, Adult, MiddleAged, Old, Ancient. + + allowed_languages: Список дополнительных языков (кроме Common). + Допустимы: Aglorondan, Alzhedo, Chessentan, Chondathan, + Chultan, Damaran, Dambrathan, Durpari, Halruaan, Illuskan, + Lantanese, Midani, Mulani, Mulhorandi, Mulhorandi (var.), + Nexalan, Rashemi, Serusan, Shaaran, Koryo, Shou, Tashalan, + Tuigan, Turmic, Uluik, Undercommon, Untheric, Dwarvish, + Elvish, Draconic, Giant, Gnomish, Goblin, Halfling, Orc, + Infernal, Celestial, Primordial, Sylvan, Abyssal, Gith, Leonin. + Пустой список = все живые языки. + + count: Количество NPC для генерации. По умолчанию 1. + level: Уровень NPC. По умолчанию 1. + """ + gender: str # "M", "F", "Any" — по умолчанию "Any" + allowed_races: list[str] + allowed_classes: list[str] + commoner_chance: float + min_age: str + max_age: str + allowed_languages: list[str] + count: int + level: int + + +def generate_npc(debug_description: str, constraints: list[HenchmanConstraint]) -> str: + """ + Основной метод генерации NPC. + + Принимает список объектов-ограничений. Для каждого объекта: + - нормирует вероятности рас, возрастов, языков + относительно допустимого подмножества; + - выбирает класс двухуровневым методом (Commoner vs боевые); + - генерирует `count` NPC на уровне `level`. + + Возвращает Markdown-строку со всеми сгенерированными NPC. + """ + if not constraints: + constraints = [{}] + + sections: list[str] = [] + + for idx, constraint in enumerate(constraints, start=1): + allowed_races = constraint.get("allowed_races", []) + min_age = constraint.get("min_age", None) + max_age = constraint.get("max_age", None) + allowed_classes = constraint.get("allowed_classes", []) + allowed_langs = constraint.get("allowed_languages", []) + commoner_chance = float( + constraint.get("commoner_chance", DEFAULT_COMMONER_CHANCE)) + count = int(constraint.get("count", 1)) + level = int(constraint.get("level", 1)) + gender_param = constraint.get("gender", "Any") + + # Нормируем пулы — это ключевая логика: + # Если из 10 доступных рас разрешены только 2, их относительные + # шансы нормируются к 100% и рандом идёт только в этом подпространстве. + race_chances = _filtered_race_chances(allowed_races) + age_chances = _filtered_age_chances(min_age, max_age) + lang_chances = _filtered_language_chances(allowed_langs) + + header = f"### Набор {idx} (lvl {level}, {count} шт.)\n" + lines = [header] + + for j in range(1, count + 1): + npc_line = _generate_single_npc( + level, + race_chances, + age_chances, + allowed_classes, + commoner_chance, + lang_chances, + gender_param, + ) + lines.append(f"#{j} — {npc_line}") + + sections.append("\n".join(lines)) + + return "\n\n---\n\n".join(sections) + + +# ────────────────────────────────────────────────────────────────────────────── +# Быстрый тест при запуске напрямую +# ────────────────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + result = generate_npc("", [ + { + "allowed_races": ["Human", "Elf", "Dwarf"], + "min_age": "Youth", + "max_age": "Old", + "allowed_classes": ["Fighter", "Rogue", "Ranger", "Commoner"], + "commoner_chance": 0.90, + "allowed_languages":["Elvish", "Dwarvish", "Illuskan"], + "count": 3, + "level": 2, + }, + { + # Без ограничений — случайные NPC, 90% коммонеры + "count": 2, + "level": 5, + }, + ]) + print(result) diff --git a/generate_npc/languages.js b/generate_npc/languages.js new file mode 100644 index 0000000..6a36e94 --- /dev/null +++ b/generate_npc/languages.js @@ -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 +} \ No newline at end of file diff --git a/generate_npc/languages.py b/generate_npc/languages.py new file mode 100644 index 0000000..055249b --- /dev/null +++ b/generate_npc/languages.py @@ -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"] \ No newline at end of file diff --git a/generate_npc/norminv.js b/generate_npc/norminv.js new file mode 100644 index 0000000..50690d2 --- /dev/null +++ b/generate_npc/norminv.js @@ -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 +} \ No newline at end of file diff --git a/generate_npc/norminv.py b/generate_npc/norminv.py new file mode 100644 index 0000000..50b93ad --- /dev/null +++ b/generate_npc/norminv.py @@ -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 \ No newline at end of file diff --git a/generate_npc/psychoTypes.js b/generate_npc/psychoTypes.js new file mode 100644 index 0000000..dd5e6c0 --- /dev/null +++ b/generate_npc/psychoTypes.js @@ -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, +}; diff --git a/generate_npc/psycho_types.py b/generate_npc/psycho_types.py new file mode 100644 index 0000000..5ac368f --- /dev/null +++ b/generate_npc/psycho_types.py @@ -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), + } \ No newline at end of file diff --git a/generate_npc/races.js b/generate_npc/races.js new file mode 100644 index 0000000..5d27e9e --- /dev/null +++ b/generate_npc/races.js @@ -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 +} \ No newline at end of file diff --git a/generate_npc/races.py b/generate_npc/races.py new file mode 100644 index 0000000..5c41254 --- /dev/null +++ b/generate_npc/races.py @@ -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 \ No newline at end of file diff --git a/generate_npc/skills.js b/generate_npc/skills.js new file mode 100644 index 0000000..b5a8aba --- /dev/null +++ b/generate_npc/skills.js @@ -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 +} \ No newline at end of file diff --git a/generate_npc/skills.py b/generate_npc/skills.py new file mode 100644 index 0000000..bde1fd9 --- /dev/null +++ b/generate_npc/skills.py @@ -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 \ No newline at end of file diff --git a/generate_trap/generate_trap (old).py b/generate_trap/generate_trap (old).py new file mode 100644 index 0000000..82735a4 --- /dev/null +++ b/generate_trap/generate_trap (old).py @@ -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: Средний уровень группы (1–20). + По умолчанию: 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)) diff --git a/generate_trap/generate_trap.py b/generate_trap/generate_trap.py new file mode 100644 index 0000000..2b680ee --- /dev/null +++ b/generate_trap/generate_trap.py @@ -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: Средний уровень группы (1–20). + По умолчанию: 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)) diff --git a/generate_trap/traps_tables.md b/generate_trap/traps_tables.md new file mode 100644 index 0000000..7d3364f --- /dev/null +++ b/generate_trap/traps_tables.md @@ -0,0 +1,139 @@ +Вы правы, забыл хиты. Добавляю колонку **HP** (Hit Points) — среднее значение из диапазона `Рек. Хиты` для соответствующего CR, округлённое до целого. + +Для дробных CR (0.125, 0.25, 0.5) беру данные из вашей таблицы: +- CR 0.125 → 7–35 → среднее 21 +- CR 0.25 → 36–49 → среднее 43 +- CR 0.5 → 50–70 → среднее 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-описания. + +Начну писать код. После завершения выложу его сюда для проверки. \ No newline at end of file diff --git a/generate_treasure/generate_treasure.py b/generate_treasure/generate_treasure.py new file mode 100644 index 0000000..2349fc5 --- /dev/null +++ b/generate_treasure/generate_treasure.py @@ -0,0 +1,1582 @@ +from random import * +from typing_extensions import TypedDict + + +class LootRequest(TypedDict, total=False): + """ + Параметры генерации лута для D&D 5e. + + Поля: + category: Категория сокровища. + Допустимы: "individual" (индивидуальное), "hoard" (сокровищница). + По умолчанию: "hoard". + + challenge: Диапазон уровней сложности. + Допустимы: "0-4", "5-10", "11-16", "17+". + По умолчанию: "0-4". + + count: Количество генераций (сколько раз бросить таблицу). + Допустимы: 1–100. + По умолчанию: 1. + """ + category: str # "individual" | "hoard", default "hoard" + challenge: str # "0-4" | "5-10" | "11-16" | "17+", default "0-4" + count: int # 1–100, default 1 + +def generate_treasure(debug_description: str, requests: list[LootRequest]) -> str: + """ + Основной метод, который вызывает тул с фронта. + + Принимает список запросов на генерацию лута. Для каждого запроса: + - определяет категорию и диапазон уровней; + - генерирует указанное количество раз; + - форматирует результат в Markdown-секцию. + + Возвращает Markdown-строку со всеми результатами. + """ + if not requests: + requests = [{}] + sections = [] + for i, req in enumerate(requests, start=1): + category = req.get("category", "hoard") + challenge = req.get("challenge", "0-4") + count = req.get("count", 1) + + # Формируем ключ для вызова нужной функции + if category == "individual": + key = f"Individual Treasure: Challenge {challenge}" + else: + key = f"Treasure Hoard: Challenge {challenge}" + + lines = [] + for _ in range(count): + item = _run_loot_type(key) + if isinstance(item, list): + lines.extend(item) + else: + lines.append(item) + + section = f"## Запрос {i}: {category.title()} — Challenge {challenge}\n\n" + section += "\n".join(f"- {l}" for l in lines) # \n\n + sections.append(section) + + return "\n\n---\n\n".join(sections) + +def _run_loot_type(tp: str): + """ + Внутренняя функция: по строковому ключу вызывает нужную таблицу лута. + """ + if tp == 'Treasure Hoard: Challenge 0-4': + return hoard1() + elif tp == 'Treasure Hoard: Challenge 5-10': + return hoard2() + elif tp == 'Treasure Hoard: Challenge 11-16': + return hoard3() + elif tp == 'Treasure Hoard: Challenge 17+': + return hoard4() + elif tp == 'Individual Treasure: Challenge 0-4': + return individual1() + elif tp == 'Individual Treasure: Challenge 5-10': + return individual2() + elif tp == 'Individual Treasure: Challenge 11-16': + return individual3() + elif tp == 'Individual Treasure: Challenge 17+': + return individual4() + elif tp == '10 GP Gemstones': + return gem10() + elif tp == '50 GP Gemstones': + return gem50() + elif tp == '100 GP Gemstones': + return gem100() + elif tp == '500 GP Gemstones': + return gem500() + elif tp == '1000 GP Gemstones': + return gem1000() + elif tp == '5000 GP Gemstones': + return gem5000() + elif tp == '25 GP Art Objects': + return art25() + elif tp == '250 GP Art Objects': + return art250() + elif tp == '750 GP Art Objects': + return art750() + elif tp == '2500 GP Art Objects': + return art2500() + elif tp == '7500 GP Art Objects': + return art7500() + elif tp == 'Magic Item Table A': + return itemA() + elif tp == 'Magic Item Table B': + return itemB() + elif tp == 'Magic Item Table C': + return itemC() + elif tp == 'Magic Item Table D': + return itemD() + elif tp == 'Magic Item Table E': + return itemE() + elif tp == 'Magic Item Table F': + return itemF() + elif tp == 'Magic Item Table G': + return itemG() + elif tp == 'Magic Item Table H': + return itemH() + elif tp == 'Magic Item Table I': + return itemI() + return "Unknown loot type" + +#Make a roll and return its value +def dice(ddice): + '''ex: 2d8+2''' + num, val = ddice.split('d') + add = 0 + if '+' in val: + val, add = val.split('+') + elif '-' in val: + val, add = val.split('-') + add = -int(add) + roll = 0 + for i in range(0,int(num)): + roll += randrange(1,int(val)+1) + return roll+int(add) + +#Gem loot +def gem10(): + val=dice('1d12') + lootList = ['Azurite','Banded Agate','Blue quartz','Eye agate','Hematite','Lapis lazuli','Malachite','Moss agate','Obsidian','Rhodochrosite','Tiger eye','Turquoise'] + loot = lootList[val-1]+ ' (10GP)' + return(loot) + +def gem50(): + val=dice('1d12') + lootList = ['Bloodstone','Carnelian','Chalcedony','Chrysoprase','Citrine','Jasper','Moonstone','Onyx','Quartz','Sardonyx','Star rose quartz','Zircon'] + loot = lootList[val-1]+ ' (50GP)' + return(loot) + +def gem100(): + val=dice('1d10') + lootList = ['Amber','Amethyst','Chrysoberyl','Coral','Garnet','Jade','Jet','Pearl','Spinel','Tourmaline'] + loot = lootList[val-1]+ ' (100GP)' + return(loot) + +def gem500(): + val=dice('1d6') + lootList = ['Alexandrite','Aquamarine','Blue pearl','Blue spinel','Peridot','Topaz'] + loot = lootList[val-1]+ ' (500GP)' + return(loot) + +def gem1000(): + val=dice('1d8') + lootList = ['Black opal','Blue sapphire','Emerald','Fire opal','Opal','Star ruby','Star sapphire','Yellow sapphire'] + loot = lootList[val-1]+ ' (1000GP)' + return(loot) + +def gem5000(): + val=dice('1d4') + lootList = ['Black sapphire','Diamond','Jacinth','Ruby'] + loot = lootList[val-1]+ ' (5000GP)' + return(loot) + +#Art objects +def art25(): + val=dice('1d10') + lootList = ['Silver ewer','Carved bone statuette','Small gold bracelet','Cloth-of-gold vestments','Black velvet mask stitched with silver thread','Copper chalice with silver filigree','Pair of engraved bone dice','Small mirror set in a painted wooden frame','Embroidered silk handkerchief','Gold locket with a painted portrait inside'] + loot = lootList[val-1]+ ' (25GP)' + return(loot) + +def art250(): + val=dice('1d10') + lootList = ['Gold ring set with bloodstones','Carved ivory statuette','Large gold bracelet','Silver necklace with a gemstone pendant','Bronze crown','Silk robe with gold embroidery','Large well-made tapestry','Brass mug with jade inlay','Box of turquoise animal figurines','Gold bird cage with electrum filigree'] + loot = lootList[val-1]+ ' (250GP)' + return(loot) + +def art750(): + val=dice('1d10') + lootList = ['Silver chalice set with moonstones', 'Silver-plated steel longsword with jet set in hilt', 'Carved harp of exotic wood with ivory inlay and zircon gems', 'Small gold idol', 'Gold dragon comb set with red garnets as eyes','Bottle stopper cork embossed with gold leaf and set with amethysts', 'Ceremonial electrum dagger with a black pearl in the pommel', 'Silver and gold brooch', 'Obsidian statuette with gold fittings and inlay', 'Painted gold war mask'] + loot = lootList[val-1]+ ' (750GP)' + return(loot) + +def art2500(): + val=dice('1d10') + lootList = ['Fine gold chain set with a fire opal','Old masterpiece painting','Embroidered silk and velvet mantle set with numerous moonstones','Platinum bracelet set with a sapphire','Embroidered glove set with jewel chips','Jeweled anklet','Gold music box','Gold circlet set with four aquamarines','Eye patch with a mock eye set in blue sapphire and moonstone','A necklace string of small pink pearls'] + loot = lootList[val-1]+ ' (2500GP)' + return(loot) + +def art7500(): + val=dice('1d8') + lootList = ['Jeweled gold crown','Jeweled platinum ring','Small gold statuette set with rubies','Gold cup set with emeralds','Gold jewelry box with platinum filigree',"Painted gold child's sarcophagus",'Jade game board with solid gold playing pieces','Bejeweled ivory drinking horn with gold filigree'] + loot = lootList[val-1]+ ' (7500GP)' + return(loot) + +#Magic items tables +def itemA(): + val=dice('1d100') + if val <= 50: + loot = 'Potion of healing' + elif val <= 60: + loot = 'Spell scroll (cantrip)' + elif val <= 70: + loot = 'Potion of climbing' + elif val <= 90: + loot = 'Spell scroll (1st level)' + elif val <= 94: + loot = 'Spell scroll (2nd level)' + elif val <= 98: + loot = 'Potion of greater healing' + elif val <= 99: + loot = 'Bag of holding' + else: + loot = 'Driftglobe' + return(loot) + +def itemB(): + val=dice('1d100') + if val <= 15: + loot = "Potion of greater healing" + elif val <= 22: + loot = "Potion of fire breath" + elif val <= 29: + loot = "Potion of resistance" + elif val <= 34: + loot = "Ammunition, +1" + elif val <= 39: + loot = "Potion of animal friendship" + elif val <= 44: + loot = "Potion of hill giant strength" + elif val <= 49: + loot = "Potion of growth" + elif val <= 54: + loot = "Potion of water breathing" + elif val <= 59: + loot = "Spell scroll (2nd level)" + elif val <= 64: + loot = "Spell scroll (3rd leve l)" + elif val <= 67: + loot = "Bag of holding" + elif val <= 70: + loot = "Keoghtom's ointment" + elif val <= 73: + loot = "Oil of slipperiness" + elif val <= 75: + loot = "Dust of disappearance" + elif val <= 77: + loot = "Dust of dryness" + elif val <= 79: + loot = "Dust of sneezing and choking" + elif val <= 81: + loot = "Elemental gem" + elif val <= 83: + loot = "Philter of love" + elif val <= 84: + loot = "Alchemy jug" + elif val <= 85: + loot = "Cap of water breathing" + elif val <= 86: + loot = "Cloak of the manta ray" + elif val <= 87: + loot = "Driftglobe" + elif val <= 88: + loot = "Goggles of night" + elif val <= 89: + loot = "Helm of comprehending languages" + elif val <= 90: + loot = "Immovable rod" + elif val <= 91: + loot = "Lantern of revealing" + elif val <= 92: + loot = "Mariner's armor" + elif val <= 93: + loot = "Mithral armor" + elif val <= 94: + loot = "Potion of poison" + elif val <= 95: + loot = "Ring of swimming" + elif val <= 96: + loot = "Robe of useful items" + elif val <= 97: + loot = "Rope of climbing" + elif val <= 98: + loot = "Saddle of the cavalier" + elif val <= 99: + loot = "Wand of magic detection" + elif val <= 100: + loot = "Wand of secrets" + return(loot) + +def itemC(): + val = dice('1d100') + if val <= 15: + loot = "Potion of superior healing" + elif val <= 22: + loot = "Spell scroll (4th level)" + elif val <= 27: + loot = "Ammunition, +2" + elif val <= 32: + loot = "Potion of clairvoyance" + elif val <= 37: + loot = "Potion of diminution" + elif val <= 42: + loot = "Potion of gaseous form" + elif val <= 47: + loot = "Potion of frost giant strength" + elif val <= 52: + loot = "Potion of stone giant strength" + elif val <= 57: + loot = "Potion of heroism" + elif val <= 62: + loot = "Potion of invulnerability" + elif val <= 67: + loot = "Potion of mind reading" + elif val <= 72: + loot = "Spell scroll (5th le vel)" + elif val <= 75: + loot = "Elixir of health" + elif val <= 78: + loot = "Oil of etherealness" + elif val <= 81: + loot = "Potion of fire giant strength" + elif val <= 84: + loot = "Quaal's feather token" + elif val <= 87: + loot = "Scroll of protection" + elif val <= 89: + loot = "Bag of beans" + elif val <= 91: + loot = "Bead of force" + elif val <= 92: + loot = "Chime of opening" + elif val <= 93: + loot = "Decanter of endless water" + elif val <= 94: + loot = "Eyes of minute seeing" + elif val <= 95: + loot = "Folding boat" + elif val <= 96: + loot = "Heward's handy haversack" + elif val <= 97: + loot = "Horseshoes of speed" + elif val <= 98: + loot = "Necklace of fireballs" + elif val <= 99: + loot = "Periapt of health" + elif val <= 100: + loot = "Sending stones" + return(loot) + +def itemD(): + val=dice('1d100') + if val <= 20: + loot = "Potion of supreme healing" + elif val <= 30: + loot = "Potion of invisibility" + elif val <= 40: + loot = "Potion of speed" + elif val <= 50: + loot = "Spell scroll (6th level)" + elif val <= 57: + loot = "Spell scroll (7th level)" + elif val <= 62: + loot = "Ammunition, +3" + elif val <= 67: + loot = "Oil of sharpness" + elif val <= 72: + loot = "Potion of flying" + elif val <= 77: + loot = "Potion of cloud giant strength" + elif val <= 82: + loot = "Potion of longevity" + elif val <= 87: + loot = "Potion of vitality" + elif val <= 92: + loot = "Spell scroll (8th level)" + elif val <= 95: + loot = "Horseshoes of a zephyr" + elif val <= 98: + loot = "Nolzur's marvelous pigments" + elif val <= 99: + loot = "Bag of devouring" + elif val <= 100: + loot = "Portable hole" + return(loot) + +def itemE(): + val=dice('1d100') + if val <= 30: + loot = "Spell scroll (8th level)" + elif val <= 55: + loot = "Potion of storm giant strength" + elif val <= 70: + loot = "Potion of supreme healing" + elif val <= 85: + loot = "Spell scroll (9th level)" + elif val <= 93: + loot = "Universal solvent" + elif val <= 98: + loot = "Arrow of slaying" + elif val <= 100: + loot = "Sovereign glue" + return(loot) + +def itemF(): + val=dice('1d100') + if val <= 15: + loot = "Weapon, +1" + elif val <= 18: + loot = "Shield,+ 1" + elif val <= 21: + loot = "Sentinel shield" + elif val <= 23: + loot = "Amulet of proof against detection and location" + elif val <= 25: + loot = "Boots of elvenkind" + elif val <= 27: + loot = "Boots of striding and springing" + elif val <= 29: + loot = "Bracers of archery" + elif val <= 31: + loot = "Brooch of shielding" + elif val <= 33: + loot = "Broom of flying" + elif val <= 35: + loot = "Cloak of elvenkind" + elif val <= 37: + loot = "Cloak of protection" + elif val <= 39: + loot = "Gauntlets of ogre power" + elif val <= 41: + loot = "Hat of disguise" + elif val <= 43: + loot = "Javelin of lightning" + elif val <= 45: + loot = "Pearl of power" + elif val <= 47: + loot = "Rod of the pact keeper, + 1" + elif val <= 49: + loot = "Slippers of spider climbing" + elif val <= 51: + loot = "Staff of the adder" + elif val <= 53: + loot = "Staff of the python" + elif val <= 55: + loot = "Sword of vengeance" + elif val <= 57: + loot = "Trident of fish command" + elif val <= 59: + loot = "Wand of magic missiles" + elif val <= 61: + loot = "Wand of the war mage, + 1" + elif val <= 63: + loot = "Wand of web" + elif val <= 65: + loot = "Weapon of warning" + elif val <= 66: + loot = "Adamantine armor (chain mail)" + elif val <= 67: + loot = "Adamantine armor (chain shirt)" + elif val <= 68: + loot = "Adamantine armor (scale mail)" + elif val <= 69: + loot = "Bag of tricks (gray)" + elif val <= 70: + loot = "Bag of tricks (rust)" + elif val <= 71: + loot = "Bag of tricks (tan)" + elif val <= 72: + loot = "Boots of the winterlands" + elif val <= 73: + loot = "Circlet of blasting" + elif val <= 74: + loot = "Deck of illusions" + elif val <= 75: + loot = "Eversmoking bottle" + elif val <= 76: + loot = "Eyes of charming" + elif val <= 77: + loot = "Eyes of the eagle" + elif val <= 78: + loot = "Figurine of wondrous power (silver raven)" + elif val <= 79: + loot = "Gem of brightness" + elif val <= 80: + loot = "Gloves of missile snaring" + elif val <= 81: + loot = "Gloves of swimming and climbing" + elif val <= 82: + loot = "Gloves of thievery" + elif val <= 83: + loot = "Headband of intellect" + elif val <= 84: + loot = "Helm of telepathy" + elif val <= 85: + loot = "Instrument of the bards (Doss lute)" + elif val <= 86: + loot = "Instrument of the bards (Fochlucan bandore)" + elif val <= 87: + loot = "Instrument of the bards (Mac-Fuimidh cittern)" + elif val <= 88: + loot = "Medallion of thoughts" + elif val <= 89: + loot = "Necklace of adaptation" + elif val <= 90: + loot = "Periapt of wound closure" + elif val <= 91: + loot = "Pipes of haunting" + elif val <= 92: + loot = "Pipes of the sewers" + elif val <= 93: + loot = "Ring of jumping" + elif val <= 94: + loot = "Ring of mind shielding" + elif val <= 95: + loot = "Ring of warmth" + elif val <= 96: + loot = "Ring of water walking" + elif val <= 97: + loot = "Quiver of Ehlonna" + elif val <= 98: + loot = "Stone of good luck" + elif val <= 99: + loot = "Wind fan" + elif val <= 100: + loot = "Winged boots" + return(loot) + +def itemG(): + val=dice('1d100') + if val <= 11: + loot = "Weapon, +2" + elif val <= 14: + loot = "Figurine of wondrous power" + val2=dice('1d8') + dollList=['Bronze griffon','Ebony fly','Golden lions','Ivory goats','Marble elephant','Onyx dog','Onyx dog','Serpentine owl'] + loot += ' (' + dollList[val2-1] + ')' + elif val <= 15: + loot = "Adamantine armor (breastplate)" + elif val <= 16: + loot = "Adamantine armor (splint)" + elif val <= 17: + loot = "Amulet of health" + elif val <= 18: + loot = "Armor of vulnerability" + elif val <= 19: + loot = "Arrow-catching shield" + elif val <= 20: + loot = "Belt of dwarvenkind" + elif val <= 21: + loot = "Belt of hill giant strength" + elif val <= 22: + loot = "Berserker axe" + elif val <= 23: + loot = "Boots of levitation" + elif val <= 24: + loot = "Boots of speed" + elif val <= 25: + loot = "Bowl of commanding water elementals" + elif val <= 26: + loot = "Bracers of defense" + elif val <= 27: + loot = "Brazier of commanding fire elementals" + elif val <= 28: + loot = "Cape of the mountebank" + elif val <= 29: + loot = "Censer of controlling air elementals" + elif val <= 30: + loot = "Armor, +1 chain mail" + elif val <= 31: + loot = "Armor of resistance (chain mail)" + elif val <= 32: + loot = "Armor,+ 1 chain shirt" + elif val <= 33: + loot = "Armor of resistance (chain shirt)" + elif val <= 34: + loot = "Cloak of displacement" + elif val <= 35: + loot = "Cloak of the bat" + elif val <= 36: + loot = "Cube of force" + elif val <= 37: + loot = "Daern's instant fortress" + elif val <= 38: + loot = "Dagger of venom" + elif val <= 39: + loot = "Dimensional shackles" + elif val <= 40: + loot = "Dragon slayer" + elif val <= 41: + loot = "Elven chain" + elif val <= 42: + loot = "Flame tongue" + elif val <= 43: + loot = "Gem of seeing" + elif val <= 44: + loot = "Giant slayer" + elif val <= 45: + loot = "Clamoured studded leather" + elif val <= 46: + loot = "Helm of teleportation" + elif val <= 47: + loot = "Horn of blasting" + elif val <= 48: + loot = "Horn of Valhalla (silver or brass)" + elif val <= 49: + loot = "Instrument of the bards (Canaith mandolin)" + elif val <= 50: + loot = "Instrument of the bards (Cli lyre)" + elif val <= 51: + loot = "Ioun stone (awareness)" + elif val <= 52: + loot = "Ioun stone (protection)" + elif val <= 53: + loot = "Ioun stone (reserve)" + elif val <= 54: + loot = "Ioun stone (sustenance)" + elif val <= 55: + loot = "Iron bands of Bilarro" + elif val <= 56: + loot = "Armor, + 1 leather" + elif val <= 57: + loot = "Armor of resistance (leather)" + elif val <= 58: + loot = "Mace of disruption" + elif val <= 59: + loot = "Mace of smiting" + elif val <= 60: + loot = "Mace of terror" + elif val <= 61: + loot = "Mantle of spell resistance" + elif val <= 62: + loot = "Necklace of prayer beads" + elif val <= 63: + loot = "Periapt of proof against poison" + elif val <= 64: + loot = "Ring of animal influence" + elif val <= 65: + loot = "Ring of evasion" + elif val <= 66: + loot = "Ring of feather falling" + elif val <= 67: + loot = "Ring of free action" + elif val <= 68: + loot = "Ring of protection" + elif val <= 69: + loot = "Ring of resistance" + elif val <= 70: + loot = "Ring of spell storing" + elif val <= 71: + loot = "Ring of the ram" + elif val <= 72: + loot = "Ring of X-ray vision" + elif val <= 73: + loot = "Robe of eyes" + elif val <= 74: + loot = "Rod of rulership" + elif val <= 75: + loot = "Rod of the pact keeper, +2" + elif val <= 76: + loot = "Rope of entanglement" + elif val <= 77: + loot = "Armor, +1 scale mail" + elif val <= 78: + loot = "Armor of resistance (scale mail)" + elif val <= 79: + loot = "Shield, +2" + elif val <= 80: + loot = "Shield of missile attraction" + elif val <= 81: + loot = "Staff of charming" + elif val <= 82: + loot = "Staff of healing" + elif val <= 83: + loot = "Staff of swarming insects" + elif val <= 84: + loot = "Staff of the woodlands" + elif val <= 85: + loot = "Staff of withering" + elif val <= 86: + loot = "Stone of controlling earth elementals" + elif val <= 87: + loot = "Sun blade" + elif val <= 88: + loot = "Sword of life stealing" + elif val <= 89: + loot = "Sword of wounding" + elif val <= 90: + loot = "Tentacle rod" + elif val <= 91: + loot = "Vicious weapon" + elif val <= 92: + loot = "Wand of binding" + elif val <= 93: + loot = "Wand of enemy detection" + elif val <= 94: + loot = "Wand of fear" + elif val <= 95: + loot = "Wand of fireballs" + elif val <= 96: + loot = "Wand of lightning bolts" + elif val <= 97: + loot = "Wand of paralysis" + elif val <= 98: + loot = "Wand of the war mage, +2" + elif val <= 99: + loot = "Wand of wonder" + elif val <= 100: + loot = "Wings of flying" + return(loot) + +def itemH(): + val=dice('1d100') + if val <= 10: + loot = "Weapon, +3" + elif val <= 12: + loot = "Amulet of the planes" + elif val <= 14: + loot = "Carpet of flying" + elif val <= 16: + loot = "Crystal ball (very rare version)" + elif val <= 18: + loot = "Ring of regeneration" + elif val <= 20: + loot = "Ring of shooting stars" + elif val <= 22: + loot = "Ring of telekinesis" + elif val <= 24: + loot = "Robe of scintillating colors" + elif val <= 26: + loot = "Robe of stars" + elif val <= 28: + loot = "Rod of absorption" + elif val <= 30: + loot = "Rod of alertness" + elif val <= 32: + loot = "Rod of security" + elif val <= 34: + loot = "Rod of the pact keeper, +3" + elif val <= 36: + loot = "Scimitar of speed" + elif val <= 38: + loot = "Shield, +3" + elif val <= 40: + loot = "Staff of fire" + elif val <= 42: + loot = "Staff of frost" + elif val <= 44: + loot = "Staff of power" + elif val <= 46: + loot = "Staff of striking" + elif val <= 48: + loot = "Staff of thunder and lightning" + elif val <= 50: + loot = "Sword of sharpness" + elif val <= 52: + loot = "Wand of polymorph" + elif val <= 54: + loot = "Wand of the war mage, +3" + elif val <= 55: + loot = "Adamantine armor (half plate)" + elif val <= 56: + loot = "Adamantine armor (plate)" + elif val <= 57: + loot = "Animated shield" + elif val <= 58: + loot = "Belt of fire giant strength" + elif val <= 59: + loot = "Belt of frost (or stone) giant strength" + elif val <= 60: + loot = "Armor, + 1 breastplate" + elif val <= 61: + loot = "Armor of resistance (breastplate)" + elif val <= 62: + loot = "Candle of invocation" + elif val <= 63: + loot = "Armor, +2 chain mail" + elif val <= 64: + loot = "Armor, +2 chain shirt" + elif val <= 65: + loot = "Cloak of arachnida" + elif val <= 66: + loot = "Dancing sword" + elif val <= 67: + loot = "Demon armor" + elif val <= 68: + loot = "Dragon scale mail" + elif val <= 69: + loot = "Dwarven plate" + elif val <= 70: + loot = "Dwarven thrower" + elif val <= 71: + loot = "Efreeti bottle" + elif val <= 72: + loot = "Figurine of wondrous power (obsidian steed)" + elif val <= 73: + loot = "Frost brand" + elif val <= 74: + loot = "Helm of brilliance" + elif val <= 75: + loot = "Horn of Valhalla (bronze)" + elif val <= 76: + loot = "Instrument of the bards (Anstruth harp)" + elif val <= 77: + loot = "Ioun stone (absorption)" + elif val <= 78: + loot = "Ioun stone (agility)" + elif val <= 79: + loot = "Ioun stone (fortitude)" + elif val <= 80: + loot = "Ioun stone (insight)" + elif val <= 81: + loot = "Ioun stone (intellect)" + elif val <= 82: + loot = "Ioun stone (leadership)" + elif val <= 83: + loot = "Ioun stone (strength)" + elif val <= 84: + loot = "Armor, +2 leather" + elif val <= 85: + loot = "Manual of bodily health" + elif val <= 86: + loot = "Manual of gainful exercise" + elif val <= 87: + loot = "Manual of golems" + elif val <= 88: + loot = "Manual of quickness of action" + elif val <= 89: + loot = "Mirror of life trapping" + elif val <= 90: + loot = "Nine lives stealer" + elif val <= 91: + loot = "Oath bow" + elif val <= 92: + loot = "Armor, +2 scale mail" + elif val <= 93: + loot = "Spellguard shield" + elif val <= 94: + loot = "Armor, + 1 splint" + elif val <= 95: + loot = "Armor of resistance (splint)" + elif val <= 96: + loot = "Armor, + 1 studded leather" + elif val <= 97: + loot = "Armor of resistance (studded leather)" + elif val <= 98: + loot = "Tome of clear thought" + elif val <= 99: + loot = "Tome of leadership and influence" + elif val <= 100: + loot = "Tome of understanding" + return(loot) + +def itemI(): + val=dice('1d100') + if val <= 5: + loot = "Defender" + elif val <= 10: + loot = "Hammer of thunderbolts" + elif val <= 15: + loot = "Luck blade" + elif val <= 20: + loot = "Sword of answering" + elif val <= 23: + loot = "Holy avenger" + elif val <= 26: + loot = "Ring of djinni summoning" + elif val <= 29: + loot = "Ring of invisibility" + elif val <= 32: + loot = "Ring of spell turning" + elif val <= 35: + loot = "Rod of lordly might" + elif val <= 38: + loot = "Staff of the magi" + elif val <= 41: + loot = "Vorpal sword" + elif val <= 43: + loot = "Belt of cloud giant strength" + elif val <= 45: + loot = "Armor, +2 breastplate" + elif val <= 47: + loot = "Armor, +3 chain mail" + elif val <= 49: + loot = "Armor, +3 chain shirt" + elif val <= 51: + loot = "Cloak of invisibility" + elif val <= 53: + loot = "Crystal ball (legendary version)" + elif val <= 55: + loot = "Armor, + 1 half plate" + elif val <= 57: + loot = "Iron flask" + elif val <= 59: + loot = "Armor, +3 leather" + elif val <= 61: + loot = "Armor, +1 plate" + elif val <= 63: + loot = "Robe of the archmagi" + elif val <= 65: + loot = "Rod of resurrection" + elif val <= 67: + loot = "Armor, +1 scale mail" + elif val <= 69: + loot = "Scarab of protection" + elif val <= 71: + loot = "Armor, +2 splint" + elif val <= 73: + loot = "Armor, +2 studded leather" + elif val <= 75: + loot = "Well of many worlds" + elif val <= 76: + loot = "Magic armor (" + val2=dice('1d12') + if val2 <= 2 : + loot += "+2 half plate)" + elif val2 <= 4 : + loot += "+2 plate)" + elif val2 <= 6 : + loot += "+3 studded leather)" + elif val2 <= 8 : + loot += "+3 breastplate)" + elif val2 <= 10: + loot += "+3 splint)" + elif val2 <= 11: + loot += "+3 half plate)" + elif val2 <= 12: + loot += "+3 plate)" + elif val <= 77: + loot = "Apparatus of Kwalish" + elif val <= 78: + loot = "Armor of invulnerability" + elif val <= 79: + loot = "Belt of storm giant strength" + elif val <= 80: + loot = "Cubic gate" + elif val <= 81: + loot = "Deck of many things" + elif val <= 82: + loot = "Efreeti chain" + elif val <= 83: + loot = "Armor of resistance (half plate)" + elif val <= 84: + loot = "Horn of Valhalla (iron)" + elif val <= 85: + loot = "Instrument of the bards (Ollamh harp)" + elif val <= 86: + loot = "Ioun stone (greater absorption)" + elif val <= 87: + loot = "Ioun stone (mastery)" + elif val <= 88: + loot = "Ioun stone (regeneration)" + elif val <= 89: + loot = "Plate armor of etherealness" + elif val <= 90: + loot = "Plate armor of resistance" + elif val <= 91: + loot = "Ring of air elemental command" + elif val <= 92: + loot = "Ring of earth elemental command" + elif val <= 93: + loot = "Ring of fire elemental command" + elif val <= 94: + loot = "Ring of three wishes" + elif val <= 95: + loot = "Ring of water elemental command" + elif val <= 96: + loot = "Sphere of annihilation" + elif val <= 97: + loot = "Talisman of pure good" + elif val <= 98: + loot = "Talisman of the sphere" + elif val <= 99: + loot = "Talisman of ultimate evil" + elif val <= 100: + loot = "Tome of the stilled tongue" + return(loot) + +#Individual loot +def individual1(): + val=dice('1d100') + if val <= 30: + loot = str(dice('5d6')) + 'CP' + elif val <= 60: + loot = str(dice('4d6')) + 'SP' + elif val <= 70: + loot = str(dice('3d6')) + 'EP' + elif val <= 95: + loot = str(dice('3d6')) + 'GP' + else: + loot = str(dice('1d6')) + 'PP' + return(loot) + +def individual2(): + val=dice('1d100') + loot = [] + if val <= 30: + loot.append(str(dice('4d6')*100) + 'CP') + loot.append(str(dice('1d6')*10) + 'EP') + elif val <= 60: + loot.append(str(dice('6d6')*10) + 'SP') + loot.append(str(dice('2d6')*10) + 'GP') + elif val <= 70: + loot.append(str(dice('1d6')*10) + 'EP') + loot.append(str(dice('2d6')*10) + 'GP') + elif val <= 95: + loot.append(str(dice('4d6')*10) + 'GP') + else: + loot.append(str(dice('2d6')*10) + 'GP') + loot.append(str(dice('3d6')) + 'PP') + return(loot) + +def individual3(): + val=dice('1d100') + loot = [] + if val <= 20: + loot.append(str(dice('4d6')*100) + 'SP') + loot.append(str(dice('1d6')*100) + 'GP') + elif val <= 35: + loot.append(str(dice('1d6')*100) + 'EP') + loot.append(str(dice('1d6')*100) + 'GP') + elif val <= 75: + loot.append(str(dice('2d6')*100) + 'GP') + loot.append(str(dice('1d6')*10) + 'PP') + else: + loot.append(str(dice('2d6')*100) + 'GP') + loot.append(str(dice('2d6')*10) + 'PP') + return(loot) + +def individual4(): + val=dice('1d100') + loot = [] + if val <= 15: + loot.append(str(dice('2d6')*1000) + 'EP') + loot.append(str(dice('8d6')*100) + 'GP') + elif val <= 55: + loot.append(str(dice('1d6')*1000) + 'GP') + loot.append(str(dice('1d6')*100) + 'PP') + else: + loot.append(str(dice('1d6')*1000) + 'GP') + loot.append(str(dice('2d6')*100) + 'PP') + return(loot) + +#Hoard loot +def hoard1(): + val=dice('1d100') + loot=[] + loot.append(str(dice("6d6")*100)+' CP') + loot.append(str(dice("3d6")*100)+' SP') + loot.append(str(dice("2d6")*10)+' GP') + if val<=6: + loot.append("No gems or items") + elif val<=16: + for i in range(0,dice('2d6')): + loot.append(gem10()) + loot.append("No magic items") + elif val<=26: + for i in range(0,dice('2d4')): + loot.append(art25()) + loot.append("No magic items") + elif val<=36: + for i in range(0,dice('2d6')): + loot.append(gem50()) + loot.append("No magic items") + elif val<=44: + for i in range(0,dice('2d6')): + loot.append(gem10()) + for i in range(0,dice('1d6')): + loot.append(itemA()) + elif val<=52: + for i in range(0,dice('2d4')): + loot.append(art25()) + for i in range(0,dice('1d6')): + loot.append(itemA()) + elif val<=60: + for i in range(0,dice('2d6')): + loot.append(gem50()) + for i in range(0,dice('1d6')): + loot.append(itemA()) + elif val<=65: + for i in range(0,dice('2d6')): + loot.append(gem10()) + for i in range(0,dice('1d4')): + loot.append(itemB()) + elif val<=70: + for i in range(0,dice('2d4')): + loot.append(art25()) + for i in range(0,dice('1d4')): + loot.append(itemB()) + elif val<=75: + for i in range(0,dice('2d6')): + loot.append(gem50()) + for i in range(0,dice('1d4')): + loot.append(itemB()) + elif val<=78: + for i in range(0,dice('2d6')): + loot.append(gem10()) + for i in range(0,dice('1d4')): + loot.append(itemC()) + elif val<=80: + for i in range(0,dice('2d4')): + loot.append(art25()) + for i in range(0,dice('1d4')): + loot.append(itemC()) + elif val<=85: + for i in range(0,dice('2d6')): + loot.append(gem50()) + for i in range(0,dice('1d4')): + loot.append(itemC()) + elif val<=92: + for i in range(0,dice('2d4')): + loot.append(art25()) + for i in range(0,dice('1d4')): + loot.append(itemF()) + elif val<=97: + for i in range(0,dice('2d6')): + loot.append(gem50()) + for i in range(0,dice('1d4')): + loot.append(itemF()) + elif val<=99: + for i in range(0,dice('2d4')): + loot.append(art25()) + for i in range(0,dice('1d1')): + loot.append(itemG()) + elif val<=100: + for i in range(0,dice('2d6')): + loot.append(gem50()) + for i in range(0,dice('1d1')): + loot.append(itemG()) + return(loot) + +def hoard2(): + val=dice('1d100') + loot=[] + loot.append(str(dice("2d6")*100)+' CP') + loot.append(str(dice("2d6")*1000)+' SP') + loot.append(str(dice("6d6")*100)+' GP') + loot.append(str(dice("3d6")*10)+' PP') + if val<=4: + loot.append("No gems or items") + elif val<=10: + for i in range(0,dice('2d4')): + loot.append(art25()) + loot.append("No magic items") + elif val<=16: + for i in range(0,dice('3d6')): + loot.append(gem50()) + loot.append("No magic items") + elif val<=22: + for i in range(0,dice('3d6')): + loot.append(gem100()) + loot.append("No magic items") + elif val<=28: + for i in range(0,dice('2d4')): + loot.append(art250()) + loot.append("No magic items") + elif val<=32: + for i in range(0,dice('2d4')): + loot.append(art25()) + for i in range(0,dice('1d6')): + loot.append(itemA()) + elif val<=36: + for i in range(0,dice('3d6')): + loot.append(gem50()) + for i in range(0,dice('1d6')): + loot.append(itemA()) + elif val<=40: + for i in range(0,dice('3d6')): + loot.append(gem100()) + for i in range(0,dice('1d6')): + loot.append(itemA()) + elif val<=44: + for i in range(0,dice('2d4')): + loot.append(art250()) + for i in range(0,dice('1d6')): + loot.append(itemA()) + elif val<=49: + for i in range(0,dice('2d4')): + loot.append(art25()) + for i in range(0,dice('1d4')): + loot.append(itemB()) + elif val<=54: + for i in range(0,dice('3d6')): + loot.append(gem50()) + for i in range(0,dice('1d4')): + loot.append(itemB()) + elif val<=59: + for i in range(0,dice('3d6')): + loot.append(gem100()) + for i in range(0,dice('1d4')): + loot.append(itemB()) + elif val<=63: + for i in range(0,dice('2d4')): + loot.append(art250()) + for i in range(0,dice('1d4')): + loot.append(itemB()) + elif val<=66: + for i in range(0,dice('2d4')): + loot.append(art25()) + for i in range(0,dice('1d4')): + loot.append(itemC()) + elif val<=69: + for i in range(0,dice('3d6')): + loot.append(gem50()) + for i in range(0,dice('1d4')): + loot.append(itemC()) + elif val<=72: + for i in range(0,dice('3d6')): + loot.append(gem100()) + for i in range(0,dice('1d4')): + loot.append(itemC()) + elif val<=74: + for i in range(0,dice('2d4')): + loot.append(art250()) + for i in range(0,dice('1d4')): + loot.append(itemC()) + elif val<=76: + for i in range(0,dice('2d4')): + loot.append(art25()) + for i in range(0,dice('1d1')): + loot.append(itemD()) + elif val<=78: + for i in range(0,dice('3d6')): + loot.append(gem50()) + for i in range(0,dice('1d1')): + loot.append(itemD()) + elif val<=79: + for i in range(0,dice('3d6')): + loot.append(gem100()) + for i in range(0,dice('1d1')): + loot.append(itemD()) + elif val<=80: + for i in range(0,dice('2d4')): + loot.append(art250()) + for i in range(0,dice('1d1')): + loot.append(itemD()) + elif val<=84: + for i in range(0,dice('2d4')): + loot.append(art25()) + for i in range(0,dice('1d4')): + loot.append(itemF()) + elif val<=88: + for i in range(0,dice('3d6')): + loot.append(gem50()) + for i in range(0,dice('1d4')): + loot.append(itemF()) + elif val<=91: + for i in range(0,dice('3d6')): + loot.append(gem100()) + for i in range(0,dice('1d4')): + loot.append(itemF()) + elif val<=94: + for i in range(0,dice('2d4')): + loot.append(art250()) + for i in range(0,dice('1d4')): + loot.append(itemF()) + elif val<=96: + for i in range(0,dice('3d6')): + loot.append(gem100()) + for i in range(0,dice('1d4')): + loot.append(itemG()) + elif val<=98: + for i in range(0,dice('2d4')): + loot.append(art250()) + for i in range(0,dice('1d4')): + loot.append(itemG()) + elif val<=99: + for i in range(0,dice('3d6')): + loot.append(gem100()) + for i in range(0,dice('1d1')): + loot.append(itemH()) + elif val<=100: + for i in range(0,dice('2d4')): + loot.append(art250()) + for i in range(0,dice('1d1')): + loot.append(itemH()) + return(loot) + +def hoard3(): + val=dice('1d100') + loot=[] + loot.append(str(dice("4d6")*1000)+' GP') + loot.append(str(dice("5d6")*100)+' PP') + if val<=3: + loot.append("No gems or items") + elif val<=6: + for i in range(0,dice('2d4')): + loot.append(art250()) + loot.append("No items") + elif val<=9: + for i in range(0,dice('2d4')): + loot.append(art750()) + loot.append("No items") + elif val<=12: + for i in range(0,dice('3d6')): + loot.append(gem500()) + loot.append("No items") + elif val<=15: + for i in range(0,dice('3d6')): + loot.append(gem1000()) + loot.append("No items") + elif val<=19: + for i in range(0,dice('2d4')): + loot.append(art250()) + for i in range(0,dice('1d4')): + loot.append(itemB()) + elif val<=23: + for i in range(0,dice('2d4')): + loot.append(art750()) + for i in range(0,dice('1d4')): + loot.append(itemB()) + elif val<=26: + for i in range(0,dice('3d6')): + loot.append(gem500()) + for i in range(0,dice('1d4')): + loot.append(itemB()) + elif val<=29: + for i in range(0,dice('3d6')): + loot.append(gem1000()) + for i in range(0,dice('1d4')): + loot.append(itemB()) + elif val<=35: + for i in range(0,dice('2d4')): + loot.append(art250()) + for i in range(0,dice('1d6')): + loot.append(itemC()) + elif val<=40: + for i in range(0,dice('2d4')): + loot.append(art750()) + for i in range(0,dice('1d6')): + loot.append(itemC()) + elif val<=45: + for i in range(0,dice('3d6')): + loot.append(gem500()) + for i in range(0,dice('1d6')): + loot.append(itemC()) + elif val<=50: + for i in range(0,dice('3d6')): + loot.append(gem1000()) + for i in range(0,dice('1d6')): + loot.append(itemC()) + elif val<=54: + for i in range(0,dice('2d4')): + loot.append(art250()) + for i in range(0,dice('1d4')): + loot.append(itemD()) + elif val<=58: + for i in range(0,dice('2d4')): + loot.append(art750()) + for i in range(0,dice('1d4')): + loot.append(itemD()) + elif val<=62: + for i in range(0,dice('3d6')): + loot.append(gem500()) + for i in range(0,dice('1d4')): + loot.append(itemD()) + elif val<=66: + for i in range(0,dice('3d6')): + loot.append(gem1000()) + for i in range(0,dice('1d4')): + loot.append(itemD()) + elif val<=68: + for i in range(0,dice('2d4')): + loot.append(art250()) + for i in range(0,dice('1d1')): + loot.append(itemE()) + elif val<=70: + for i in range(0,dice('2d4')): + loot.append(art750()) + for i in range(0,dice('1d1')): + loot.append(itemE()) + elif val<=72: + for i in range(0,dice('3d6')): + loot.append(gem500()) + for i in range(0,dice('1d1')): + loot.append(itemE()) + elif val<=74: + for i in range(0,dice('3d6')): + loot.append(gem1000()) + for i in range(0,dice('1d1')): + loot.append(itemE()) + elif val<=76: + for i in range(0,dice('2d4')): + loot.append(art250()) + for i in range(0,dice('1d1')): + loot.append(itemG()) + elif val<=78: + for i in range(0,dice('2d4')): + loot.append(art750()) + for i in range(0,dice('1d1')): + loot.append(itemG()) + elif val<=80: + for i in range(0,dice('3d6')): + loot.append(gem500()) + for i in range(0,dice('1d1')): + loot.append(itemG()) + elif val<=82: + for i in range(0,dice('3d6')): + loot.append(gem1000()) + for i in range(0,dice('1d1')): + loot.append(itemG()) + elif val<=85: + for i in range(0,dice('2d4')): + loot.append(art250()) + for i in range(0,dice('1d4')): + loot.append(itemH()) + elif val<=88: + for i in range(0,dice('2d4')): + loot.append(art750()) + for i in range(0,dice('1d4')): + loot.append(itemH()) + elif val<=90: + for i in range(0,dice('3d6')): + loot.append(gem500()) + for i in range(0,dice('1d4')): + loot.append(itemH()) + elif val<=92: + for i in range(0,dice('3d6')): + loot.append(gem1000()) + for i in range(0,dice('1d4')): + loot.append(itemH()) + elif val<=94: + for i in range(0,dice('2d4')): + loot.append(art250()) + for i in range(0,dice('1d1')): + loot.append(itemI()) + elif val<=96: + for i in range(0,dice('2d4')): + loot.append(art750()) + for i in range(0,dice('1d1')): + loot.append(itemI()) + elif val<=98: + for i in range(0,dice('3d6')): + loot.append(gem500()) + for i in range(0,dice('1d1')): + loot.append(itemI()) + elif val<=100: + for i in range(0,dice('3d6')): + loot.append(gem1000()) + for i in range(0,dice('1d1')): + loot.append(itemI()) + return(loot) + +def hoard4(): + val=dice('1d100') + loot=[] + loot.append(str(dice("12d6")*1000)+' GP') + loot.append(str(dice("8d6")*1000)+' PP') + if val<=5: + loot.append("No gems or items") + elif val<=8: + for i in range(0,dice('3d6')): + loot.append(gem1000()) + for i in range(0,dice('1d8')): + loot.append(itemC()) + elif val<=11: + for i in range(0,dice('1d1')): + loot.append(art2500()) + for i in range(0,dice('1d8')): + loot.append(itemC()) + elif val<=14: + for i in range(0,dice('1d4')): + loot.append(art7500()) + for i in range(0,dice('1d8')): + loot.append(itemC()) + elif val<=22: + for i in range(0,dice('1d8')): + loot.append(gem5000()) + for i in range(0,dice('1d8')): + loot.append(itemC()) + elif val<=30: + for i in range(0,dice('3d6')): + loot.append(gem1000()) + for i in range(0,dice('1d6')): + loot.append(itemD()) + elif val<=38: + for i in range(0,dice('1d1')): + loot.append(art2500()) + for i in range(0,dice('1d6')): + loot.append(itemD()) + elif val<=46: + for i in range(0,dice('1d4')): + loot.append(art7500()) + for i in range(0,dice('1d6')): + loot.append(itemD()) + elif val<=52: + for i in range(0,dice('1d8')): + loot.append(gem5000()) + for i in range(0,dice('1d6')): + loot.append(itemD()) + elif val<=58: + for i in range(0,dice('3d6')): + loot.append(gem1000()) + for i in range(0,dice('1d6')): + loot.append(itemE()) + elif val<=63: + for i in range(0,dice('1d1')): + loot.append(art2500()) + for i in range(0,dice('1d6')): + loot.append(itemE()) + elif val<=68: + for i in range(0,dice('1d4')): + loot.append(art7500()) + for i in range(0,dice('1d6')): + loot.append(itemE()) + elif val<=69: + for i in range(0,dice('1d8')): + loot.append(gem5000()) + for i in range(0,dice('1d6')): + loot.append(itemE()) + elif val<=70: + for i in range(0,dice('3d6')): + loot.append(gem1000()) + for i in range(0,dice('1d4')): + loot.append(itemG()) + elif val<=71: + for i in range(0,dice('1d1')): + loot.append(art2500()) + for i in range(0,dice('1d4')): + loot.append(itemG()) + elif val<=72: + for i in range(0,dice('1d4')): + loot.append(art7500()) + for i in range(0,dice('1d4')): + loot.append(itemG()) + elif val<=74: + for i in range(0,dice('1d8')): + loot.append(gem5000()) + for i in range(0,dice('1d4')): + loot.append(itemG()) + elif val<=76: + for i in range(0,dice('3d6')): + loot.append(gem1000()) + for i in range(0,dice('1d4')): + loot.append(itemH()) + elif val<=78: + for i in range(0,dice('1d1')): + loot.append(art2500()) + for i in range(0,dice('1d4')): + loot.append(itemH()) + elif val<=80: + for i in range(0,dice('1d4')): + loot.append(art7500()) + for i in range(0,dice('1d4')): + loot.append(itemH()) + elif val<=85: + for i in range(0,dice('1d8')): + loot.append(gem5000()) + for i in range(0,dice('1d4')): + loot.append(itemH()) + elif val<=90: + for i in range(0,dice('3d6')): + loot.append(gem1000()) + for i in range(0,dice('1d4')): + loot.append(itemI()) + elif val<=95: + for i in range(0,dice('1d1')): + loot.append(art2500()) + for i in range(0,dice('1d4')): + loot.append(itemI()) + elif val<=100: + for i in range(0,dice('1d4')): + loot.append(art7500()) + for i in range(0,dice('1d4')): + loot.append(itemI()) + return(loot) + + diff --git a/locationTable.js b/locationTable.js new file mode 100644 index 0000000..5454bde --- /dev/null +++ b/locationTable.js @@ -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 = `View`; + 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); diff --git a/locationTypesList.js b/locationTypesList.js new file mode 100644 index 0000000..30f5410 --- /dev/null +++ b/locationTypesList.js @@ -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 = `View`; + 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, +}; diff --git a/locations.js b/locations.js new file mode 100644 index 0000000..30741f4 --- /dev/null +++ b/locations.js @@ -0,0 +1,80 @@ +// Функция для получения ссылок на изображения +function getImageLinks(p, basePath) { + let avatar = p["avatar-image"]; + if (avatar && avatar.path) { + let imagePath = `${basePath}/${avatar.path}`; // Используем базовый путь хранилища + return `
+ +
`; + } + 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, +}; diff --git a/magicItemShops.js b/magicItemShops.js new file mode 100644 index 0000000..b8af531 --- /dev/null +++ b/magicItemShops.js @@ -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); diff --git a/md-files-folder-wise-list.bat b/md-files-folder-wise-list.bat new file mode 100644 index 0000000..f3f920f --- /dev/null +++ b/md-files-folder-wise-list.bat @@ -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 diff --git a/mercenariesHirePlaces.js b/mercenariesHirePlaces.js new file mode 100644 index 0000000..483e1ba --- /dev/null +++ b/mercenariesHirePlaces.js @@ -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); diff --git a/merge_markdown.bat b/merge_markdown.bat new file mode 100644 index 0000000..568bacb --- /dev/null +++ b/merge_markdown.bat @@ -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 diff --git a/npcTable.js b/npcTable.js new file mode 100644 index 0000000..04ebf93 --- /dev/null +++ b/npcTable.js @@ -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 += ` (D)`; + } + 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 = `View`; + mainInfoParts.push(appearanceTooltip); + } + if (p.personality) { + let personalityTooltip = `Pers`; + mainInfoParts.push(personalityTooltip); + } + if (p.biography) { + let biographyTooltip = `Bio`; + mainInfoParts.push(biographyTooltip); + } + if (p.quests) { + let questsTooltip = `Quests`; + 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 ? `Equip` : ""; + + // Объединяем ссылки и tooltip + return [...links, tooltip].filter(Boolean).join(", "); + } else { + // Если это не массив, просто возвращаем как есть (и проверяем на тип строки) + return typeof property === 'string' ? `Equip` : ""; + } +} + +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); diff --git a/replace_field_name.py b/replace_field_name.py new file mode 100644 index 0000000..9e8b470 --- /dev/null +++ b/replace_field_name.py @@ -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}') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..82c4bf4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PyYaml +pathspec \ No newline at end of file diff --git a/showLocationTabs.js b/showLocationTabs.js new file mode 100644 index 0000000..09496bd --- /dev/null +++ b/showLocationTabs.js @@ -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); \ No newline at end of file diff --git a/spellScrollsShops.js b/spellScrollsShops.js new file mode 100644 index 0000000..7877ebf --- /dev/null +++ b/spellScrollsShops.js @@ -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); diff --git a/sync_fields.py b/sync_fields.py new file mode 100644 index 0000000..a6a5ee0 --- /dev/null +++ b/sync_fields.py @@ -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) \ No newline at end of file diff --git a/timeline-widget.js b/timeline-widget.js new file mode 100644 index 0000000..e13e05c --- /dev/null +++ b/timeline-widget.js @@ -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 + ); +} + +/** + * Заполняем общий всеми заметками. + */ +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); + }); +} + +/** + * Добавляет новый блок с для локации + */ +function addListFormField(container, inputName, dataListName) { + // locationsDataList + const div = document.createElement("div"); + div.className = "locationContainer"; + + // Обратите внимание, указываем list="locationsDataList", а + // будет один на всю форму + div.innerHTML = ` + + + `; + + container.appendChild(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 = ` + + +
+
+
+ +
+ +
+
+ +
+
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+ + +
+
+
+ `; + + 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); diff --git a/timeline.js b/timeline.js new file mode 100644 index 0000000..1b5a7d5 --- /dev/null +++ b/timeline.js @@ -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 в начало сабграфа + 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 --> T + 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, +}; diff --git a/utils.js b/utils.js new file mode 100644 index 0000000..8cff6cc --- /dev/null +++ b/utils.js @@ -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 +}; diff --git a/weather/weather.py b/weather/weather.py new file mode 100644 index 0000000..e7dcd53 --- /dev/null +++ b/weather/weather.py @@ -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 (6–15 °F)", + 3: "Cold (16–25 °F)", + 4: "Cold (26–32 °F)", + 5: "Very Chilly (33–36 °F)", + 6: "Very Chilly (37–40 °F)", + 7: "Chilly (41–50 °F)", + 8: "Chilly (51–55 °F)", + 9: "Brisk (56–60 °F)", + 10: "Brisk (61–65 °F)", + 11: "Balmy (66–68 °F)", + 12: "Balmy (69–72 °F)", + 13: "Warm (73–76 °F)", + 14: "Warm (77–80 °F)", + 15: "Hot (81–86 °F)", + 16: "Hot (87–92 °F)", + 17: "Sweltering (93–100 °F)", + 18: "Sweltering (101–110 °F)", + 19: "Sweltering (111 °F или выше)", +} + +# Порог температурного индекса, ниже которого осадки — снег/метель, +# а не дождь. Индекс 4 = Cold (26–32 °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 (69–72 °F)' или 'Frigid (-75 °F или ниже)' + и возвращает строку с диапазоном в °C. + + Обрабатывает форматы: + (X °F или ниже) + (X до Y °F) + (X–Y °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) + + # «69–72 °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)