Debug faction turns, update planar monsters data

This commit is contained in:
dimitrievgs 2026-07-06 01:48:00 +03:00
parent e90a2e2e43
commit 1ec3b18bb1
13 changed files with 10548 additions and 466 deletions

View File

@ -0,0 +1,644 @@
"""
faction_utils.py утилиты для работы с фракциями через Obsidian vault.
Все операции с файлами (.md) выполняются через HTTP-эндпоинт бэкенда
/api/vault/query, который проксирует запрос в Obsidian через WebSocket.
Obsidian отвечает через vault_query_response.
Экспортирует:
vault_query(query_type, payload) базовый вызов
get_all_factions(folder) список всех фракций
get_faction(path) данные одной фракции
get_location(wikilink_or_path) данные локации
get_travel_distance(from_loc, to_loc) расстояние в милях
get_turns_to_travel(from_loc, to_loc) ходов на перемещение
are_within_one_move(loc_a, loc_b) в пределах одного хода?
get_assets_in_location(faction, loc) активы фракции в локации
get_factions_in_location(factions, loc) фракции в локации
get_recent_events(folder, tags, limit) последние события
write_faction(path, frontmatter, body) сохранить фракцию
create_event_file(path, content) создать файл события
normalize_wikilink(s) '[[Waterdeep]]' 'waterdeep'
resolve_wikilink(name) wikilink путь к файлу
roll_dice(notation) '2d6+1' int
roll_attribute_check(a, d, extra_a, extra_d) WWN attribute check
"""
import re
import math
import random
import requests as http_requests
from typing import Optional
from wwn_mechanics import calc_hp_max
# ---------------------------------------------------------------------------
# Настройки
# ---------------------------------------------------------------------------
BACKEND_URL = "http://localhost:5000/api"
VAULT_QUERY_TIMEOUT = 12 # секунд
# По WWN: один faction turn ≈ 1 месяц ≈ 100 миль перемещения
WWN_MILES_PER_TURN = 100
# ---------------------------------------------------------------------------
# Базовый клиент
# ---------------------------------------------------------------------------
def vault_query(query_type: str, payload: dict) -> any:
"""
Отправляет запрос к Obsidian vault через бэкенд-прокси.
Блокирующий вызов: ждёт пока Obsidian выполнит операцию.
Бросает RuntimeError при недоступности бэкенда или timeout.
Поля:
query_type: тип операции
payload: параметры операции
"""
try:
resp = http_requests.post(
f"{BACKEND_URL}/vault/query",
json={"type": query_type, "payload": payload},
timeout=VAULT_QUERY_TIMEOUT,
proxies={"http": None, "https": None}
)
except http_requests.exceptions.ConnectionError as e:
raise RuntimeError(
f"Бэкенд недоступен ({BACKEND_URL}). "
"Убедитесь что Flask-сервер запущен."
)
except http_requests.exceptions.Timeout:
raise RuntimeError(
f"vault_query timeout после {VAULT_QUERY_TIMEOUT}с. "
"Возможно Obsidian не подключён к WebSocket."
)
if resp.status_code == 504:
raise RuntimeError(
"Obsidian не ответил на запрос vault за 10 секунд. "
"Проверьте что плагин LLM Agent запущен и подключён."
)
if not resp.ok:
try:
err = resp.json().get("error", resp.text)
except Exception:
err = resp.text
raise RuntimeError(f"vault_query ошибка {resp.status_code}: {err}")
return resp.json()
# ---------------------------------------------------------------------------
# Нормализация wikilinks
# ---------------------------------------------------------------------------
def normalize_wikilink(s: str) -> str:
"""
Приводит wikilink к нижнему регистру без скобок и алиасов.
Примеры:
'[[Waterdeep]]' 'waterdeep'
'[[Waterdeep|City]]' 'waterdeep'
'Waterdeep' 'waterdeep'
'' ''
"""
if not s:
return ''
return re.sub(r'^\[\[|\]\]$', '', s).split('|')[0].strip().lower()
def resolve_wikilink(name: str) -> Optional[str]:
"""
Резолвит wikilink в путь к файлу через Obsidian metadataCache.
Знает об aliases.
Принимает '[[Waterdeep]]', '[[Waterdeep|City]]' или просто 'Waterdeep'.
Возвращает путь вида 'TTRPG/Locations/Waterdeep.md' или None.
"""
original_clean = re.sub(r'^\[\[|\]\]$', '', name).split('|')[0].strip()
result = vault_query("resolve_wikilink", {"name": original_clean})
# vault_query возвращает строку-путь или None напрямую
return result if isinstance(result, str) else None
# ---------------------------------------------------------------------------
# Фракции
# ---------------------------------------------------------------------------
def get_all_factions(
factions_folder: str = "Фракции"
) -> list[dict]:
"""
Возвращает список всех фракций из папки.
Каждый элемент:
{
"path": "Фракции/Cult_of_the_Dragon.md",
"name": "Cult_of_the_Dragon",
"frontmatter": { ... }
}
"""
result = vault_query("get_all_factions", {
"folder": factions_folder,
"tag_filter": "faction"
})
if isinstance(result, list):
factions = result
elif isinstance(result, dict):
factions = result.get("data", [])
else:
factions = []
for f in factions:
f["frontmatter"] = normalize_faction_frontmatter(f.get("frontmatter", {}))
return factions
def get_faction(file_path: str) -> dict:
"""
Возвращает данные одной фракции: frontmatter + body.
Возвращает:
{
"path": "...",
"name": "...",
"frontmatter": { ... },
"body": "# Лор фракции..."
}
"""
return vault_query("get_frontmatter", {"path": file_path})
def get_active_factions(
factions_folder: str = "Фракции"
) -> list[dict]:
"""Возвращает только активные фракции (state != 'defeated')."""
all_factions = get_all_factions(factions_folder)
return [
f for f in all_factions
if f.get("frontmatter", {}).get("state") != "defeated"
]
def get_factions_in_location(all_factions: list[dict], locations_wikilinks: list[str] = None) -> list[dict]:
"""
Возвращает список фракций, присутствующих в указанных локациях.
Если locations_wikilinks пуст или None возвращает все фракции.
"""
if not locations_wikilinks:
return all_factions
# Если locations_wikilinks — строка, преобразуем её в список
if isinstance(locations_wikilinks, str):
locations_wikilinks = [locations_wikilinks]
targets = [normalize_wikilink(loc) for loc in locations_wikilinks if loc]
if not targets:
return all_factions
result = []
for faction in all_factions:
fm = faction.get("frontmatter", {})
# Проверка списка locations фракции
locs = fm.get("locations", [])
if isinstance(locs, str): locs = [locs]
normalized_locs = [normalize_wikilink(l) for l in locs if isinstance(l, str)]
# Проверка локаций активов
asset_locs = [
normalize_wikilink(a.get("location", ""))
for a in fm.get("assets", []) if isinstance(a, dict)
]
# Если фракция есть хотя бы в одной из целевых локаций
if any(t in normalized_locs or t in asset_locs for t in targets):
result.append(faction)
return result
def get_assets_in_location(
faction_data: dict,
locations_wikilinks: list[str] = None
) -> list[dict]:
"""
Возвращает активы фракции в указанных локациях.
Если список локаций пуст возвращает ВСЕ активы фракции.
"""
fm = faction_data.get("frontmatter", faction_data)
assets = fm.get("assets", [])
if not locations_wikilinks:
return [a for a in assets if isinstance(a, dict)]
targets = [normalize_wikilink(loc) for loc in locations_wikilinks if loc]
if not targets:
return [a for a in assets if isinstance(a, dict)]
return [
a for a in assets
if isinstance(a, dict) and normalize_wikilink(a.get("location", "")) in targets
]
def write_faction(file_path: str, frontmatter: dict, body: str) -> bool:
"""
Сохраняет изменённый frontmatter фракции обратно в Obsidian.
file_path путь вида 'Фракции/Cult_of_the_Dragon.md'
frontmatter словарь с обновлёнными данными
body тело файла (лор-текст), берётся из get_faction() без изменений
"""
result = vault_query("write_frontmatter", {
"path": file_path,
"frontmatter": frontmatter,
"body": body
})
if isinstance(result, dict):
return result.get("success", False)
return False
# ---------------------------------------------------------------------------
# Локации и расстояния
# ---------------------------------------------------------------------------
def get_location(wikilink_or_path: str) -> dict:
"""
Возвращает данные локации: frontmatter + body.
Принимает '[[Waterdeep]]' или 'TTRPG/Locations/Waterdeep.md'.
frontmatter локации содержит поле travel-distance:
travel-distance:
"[[Neverwinter]]": 300
"[[Baldur's Gate]]": 250
"""
if wikilink_or_path.startswith("[[") or not wikilink_or_path.endswith(".md"):
path = resolve_wikilink(wikilink_or_path)
if not path:
raise ValueError(
f"Локация не найдена в vault: '{wikilink_or_path}'. "
"Проверьте что файл локации существует."
)
else:
path = wikilink_or_path
return vault_query("get_frontmatter", {"path": path})
def get_travel_distance(
from_location: str,
to_location: str
) -> Optional[int]:
"""
Возвращает расстояние в милях между двумя локациями.
Читает поле travel-distance из файла from_location.
Если нет прямой связи пробует обратный маршрут.
Возвращает None если расстояние не задано.
Формат поля в Location .md:
travel-distance:
"[[Neverwinter]]": 300
"[[Baldur's Gate]]": 250
"[[Daggerford]]": 50
"""
to_normalized = normalize_wikilink(to_location)
# Прямой маршрут
try:
loc_data = get_location(from_location)
distances: dict = loc_data.get("frontmatter", {}).get("travel-distance", {})
for key, dist in distances.items():
if normalize_wikilink(str(key)) == to_normalized:
return int(dist)
except (ValueError, RuntimeError):
pass
# Обратный маршрут (граф ненаправленный)
from_normalized = normalize_wikilink(from_location)
try:
loc_data_reverse = get_location(to_location)
distances_reverse: dict = loc_data_reverse.get("frontmatter", {}).get("travel-distance", {})
for key, dist in distances_reverse.items():
if normalize_wikilink(str(key)) == from_normalized:
return int(dist)
except (ValueError, RuntimeError):
pass
return None
def get_turns_to_travel(
from_location: str,
to_location: str,
miles_per_turn: int = WWN_MILES_PER_TURN
) -> Optional[int]:
"""
Возвращает количество faction turns для перемещения актива.
По WWN: один ход 100 миль. Округление вверх.
Возвращает None если расстояние не задано.
"""
dist = get_travel_distance(from_location, to_location)
if dist is None:
return None
return math.ceil(dist / miles_per_turn)
def are_within_one_move(
location_a: str,
location_b: str,
miles_per_turn: int = WWN_MILES_PER_TURN
) -> bool:
"""
Проверяет находятся ли две локации в пределах одного faction turn move.
Используется для проверки радиуса спецспособностей
(Informers, Vigilant Agents, Omniscient Seers и т.д.).
Если расстояние неизвестно возвращает True (safe default,
лучше разрешить чем молча заблокировать).
"""
dist = get_travel_distance(location_a, location_b)
if dist is None:
return True
return dist <= miles_per_turn
# ---------------------------------------------------------------------------
# События
# ---------------------------------------------------------------------------
def get_recent_events(
events_folder: str = "События",
tags_filter: Optional[list[str]] = None,
max_count: int = 5
) -> list[dict]:
"""
Возвращает последние N событий из папки events_folder.
tags_filter список тегов (хотя бы один должен совпасть).
None без фильтрации.
Каждый элемент:
{
"path": "События/2026-06-22_Cult_Attack.md",
"name": "2026-06-22_Cult_Attack",
"frontmatter": { ... }
}
"""
result = vault_query("get_recent_files", {
"folder": events_folder,
"limit": max_count,
"tags": tags_filter or []
})
if isinstance(result, list):
return result
return []
def create_event_file(file_path: str, content: str) -> bool:
"""
Создаёт файл события в Obsidian vault.
Если файл уже существует перезаписывает.
Промежуточные папки создаются автоматически.
"""
result = vault_query("create_file", {
"path": file_path,
"content": content
})
if isinstance(result, dict):
return result.get("success", False)
return False
# ---------------------------------------------------------------------------
# Кубики (WWN-механика)
# ---------------------------------------------------------------------------
def roll_dice(notation: str) -> int:
"""
Бросает кубики по нотации и возвращает результат.
Поддерживает: '1d8', '2d6', '1d10+3', '2d6+2', '1d8-1'.
Минимальный результат: 1.
"""
notation = notation.strip().lower()
match = re.fullmatch(r'(\d+)d(\d+)([+-]\d+)?', notation)
if not match:
raise ValueError(
f"Неверный формат нотации: '{notation}'. "
"Ожидается: '2d6', '1d8+2', '1d10-1'."
)
num_dice = int(match.group(1))
die_sides = int(match.group(2))
modifier = int(match.group(3)) if match.group(3) else 0
total = sum(random.randint(1, die_sides) for _ in range(num_dice)) + modifier
return max(1, total)
def roll_attribute_check(
attacker_attr: int,
defender_attr: int,
attacker_extra_die: bool = False,
defender_extra_die: bool = False,
) -> tuple[int, int, bool]:
"""
Выполняет attribute check по правилам WWN.
Оба бросают 1d10 + атрибут.
Атакующий побеждает только при СТРОГО большем результате (ничья = защитник).
attacker_extra_die True если faction tag даёт extra die
(Machiavellian/Martial/Rich): бросаются 2d10, берётся лучший.
defender_extra_die аналогично для защитника.
Возвращает: (attacker_total, defender_total, attacker_wins: bool)
"""
def _roll(attr: int, extra: bool) -> int:
if extra:
return max(random.randint(1, 10), random.randint(1, 10)) + attr
return random.randint(1, 10) + attr
a_total = _roll(attacker_attr, attacker_extra_die)
d_total = _roll(defender_attr, defender_extra_die)
return a_total, d_total, a_total > d_total
# ---------------------------------------------------------------------------
# Тест
# ---------------------------------------------------------------------------
if __name__ == "__main__":
print("=== Тест faction_utils.py ===\n")
print("--- roll_dice ---")
for notation in ["1d8", "2d6", "1d10+3", "1d6-1", "3d4+2"]:
print(f" {notation}{roll_dice(notation)}")
print("\n--- normalize_wikilink ---")
for s in [
"[[Waterdeep]]",
"[[Waterdeep|City of Splendors]]",
"Waterdeep",
"[[baldur's gate]]",
"",
]:
print(f" '{s}''{normalize_wikilink(s)}'")
print("\n--- roll_attribute_check (Force 5 vs Force 3, 10 бросков) ---")
wins = sum(1 for _ in range(10) if roll_attribute_check(5, 3)[2])
print(f" Атакующий выиграл {wins}/10")
print("\n--- roll_attribute_check с extra die (Cunning 5 vs Cunning 5) ---")
wins_extra = sum(1 for _ in range(10) if roll_attribute_check(5, 5, attacker_extra_die=True)[2])
print(f" С extra die: {wins_extra}/10 (ожидаем > 5)")
print("\n--- vault_query (требует запущенный бэкенд + Obsidian) ---")
try:
factions = get_all_factions("Фракции")
print(f" Найдено фракций: {len(factions)}")
if factions:
f = factions[0]
fm = f.get("frontmatter", {})
print(f" Первая: {f['name']} | HP: {fm.get('hp')} | Treasure: {fm.get('treasure')}")
except RuntimeError as e:
print(f" Пропущен (бэкенд недоступен): {e}")
print("\n--- get_travel_distance (требует бэкенд + Obsidian) ---")
try:
dist = get_travel_distance("[[Waterdeep]]", "[[Neverwinter]]")
turns = get_turns_to_travel("[[Waterdeep]]", "[[Neverwinter]]")
in_range = are_within_one_move("[[Waterdeep]]", "[[Daggerford]]")
print(f" Waterdeep → Neverwinter: {dist} миль, {turns} ходов")
print(f" Waterdeep → Daggerford ≤100 миль: {in_range}")
except RuntimeError as e:
print(f" Пропущен (бэкенд недоступен): {e}")
print("\n=== Готово ===")
# ---------------------------------------------------------------------------
# Нормализация фракции: дефолты для отсутствующих полей
# ---------------------------------------------------------------------------
# Дефолтные механические значения WWN для фракции
FACTION_DEFAULTS: dict = {
"force": 1,
"wealth": 1,
"cunning": 1,
"magic": "None",
"treasure": 0,
"xp": 0,
"state": "active",
"locations": [],
"current_goal": "",
"faction_tags": [],
"tags_listen": [],
"dw_category": "",
"impulse": "",
}
def _clean_list_field(value) -> list:
"""
Приводит поле-список к чистому виду:
- None []
- строка [строка] (если не пустая)
- список без пустых строк "" и None
Используется для locations, motivations, assets и т.п.
"""
if value is None:
return []
if isinstance(value, str):
return [value] if value.strip() else []
if isinstance(value, list):
cleaned = []
for item in value:
if item is None:
continue
if isinstance(item, str) and not item.strip():
continue # пустая строка-заглушка "- ''"
cleaned.append(item)
return cleaned
return []
def _clean_assets(fm: dict) -> list[dict]:
"""
Оставляет только валидные активы-объекты с непустым name.
Отбрасывает строки-заглушки ('- ""') и мусор.
Для каждого актива подставляет дефолты недостающих полей.
"""
raw = _clean_list_field(fm.get("assets"))
result = []
for a in raw:
if not isinstance(a, dict) or not a.get("name"):
continue
# Дефолты полей актива
is_boi = a.get("is_base_of_influence", False)
a.setdefault("is_base_of_influence", is_boi)
a.setdefault("location", "")
a.setdefault("stealthed", False)
a.setdefault("subtle", False)
a.setdefault("hp_max", a.get("hp", 1))
a.setdefault("hp", a.get("hp_max", 1))
a.setdefault("cost", 0)
if not is_boi:
a.setdefault("attribute", "Force")
a.setdefault("tier_required", 1)
result.append(a)
return result
def normalize_faction_frontmatter(fm: dict) -> dict:
"""
Приводит frontmatter фракции к формату, ожидаемому механикой WWN.
- Подставляет дефолты для отсутствующих скалярных полей.
- Чистит списочные поля (locations, motivations, assets...).
- Если основная 'location' не задана, но есть непустой 'locations',
берёт первый элемент как основную локацию.
- Пересчитывает hp_max по атрибутам, если не задан;
подставляет hp = hp_max, если hp не задан.
Модифицирует fm на месте и возвращает его же.
"""
# Скалярные дефолты
for key, default in FACTION_DEFAULTS.items():
if fm.get(key) is None:
fm[key] = default.copy() if isinstance(default, (list, dict)) else default
# Списочные поля (нарративные) — чистим от пустых заглушек
for list_field in ("locations", "motivations", "headquarters",
"leaders", "enemies"):
fm[list_field] = _clean_list_field(fm.get(list_field))
# tags_listen / faction_tags тоже чистим
fm["faction_tags"] = _clean_list_field(fm.get("faction_tags"))
fm["tags_listen"] = _clean_list_field(fm.get("tags_listen"))
# Активы
fm["assets"] = _clean_assets(fm)
# HP: считаем hp_max по атрибутам, если не задан
if fm.get("hp_max") is None:
fm["hp_max"] = calc_hp_max(
fm.get("force", 1),
fm.get("wealth", 1),
fm.get("cunning", 1),
)
if fm.get("hp") is None:
fm["hp"] = fm["hp_max"]
return fm

View File

@ -0,0 +1,689 @@
"""
propose_faction_turn.py Тул 1 системы фракций WWN.
Читает все фракции из Obsidian-хранилища (через бэкенд-прокси vault_query),
бросает инициативу, начисляет доход, проверяет апкип и для каждой активной
фракции формирует полный список механически допустимых действий с числами.
Экспортирует функцию propose_faction_turn(debug_description, requests),
которую вызывает тул с фронта.
requests список объектов вида:
{
"location_wikilink": "[[Waterdeep]]", # локация для хода (обязательно)
"factions_folder": "Фракции", # папка фракций (default)
"events_folder": "События", # папка событий (default)
"game_date": "15 Mirtul 1492", # игровая дата (default: today)
"max_events": 5, # контекстных событий для LLM (default 5)
}
Возвращает строку в формате Markdown механический промпт для LLM.
"""
import random
import math
from datetime import date
from typing import TypedDict
try:
from typing_extensions import TypedDict
except ImportError:
from typing import TypedDict
from faction_utils import (
vault_query,
get_all_factions,
get_factions_in_location,
get_assets_in_location,
get_recent_events,
normalize_wikilink,
roll_dice,
roll_attribute_check,
are_within_one_move,
)
from wwn_assets_data import (
get_available_assets,
get_asset,
MAGIC_LEVELS,
)
from wwn_mechanics import (
calc_income,
calc_hp_max,
calc_asset_upkeep,
calc_repair_heal,
calc_faction_repair_heal,
calc_expand_influence_cost,
get_available_upgrades,
get_attack_extra_die_flags,
has_antimagical_penalty,
check_massive_auto_win,
format_faction_status,
format_asset_line,
)
# ---------------------------------------------------------------------------
# TypedDict
# ---------------------------------------------------------------------------
class FactionTurnProposalRequest(TypedDict, total=False):
"""
Параметры одного запроса на генерацию хода фракций.
Поля:
location_wikilink: Локация на русском в формате Obsidian wikilink.
Обязательное поле.
Пример: "[[Уотердип]]", "[[Невервинтер]]", название локации на русском.
factions_folder: Путь к папке с файлами фракций внутри vault.
По умолчанию: "Фракции".
events_folder: Путь к папке с файлами событий внутри vault.
По умолчанию: "События".
game_date: Игровая дата в произвольном формате.
По умолчанию: текущая реальная дата.
Пример: "15 Mirtul 1492 DR".
max_events: Количество последних событий для контекста LLM.
Допустимы: 120.
По умолчанию: 5.
"""
location_wikilink: str # wikilink локации, обязательно: "[[Уотердип]]"
factions_folder: str # default "Фракции"
events_folder: str # default "События"
game_date: str # default: today
max_events: int # 120, default 5
# ---------------------------------------------------------------------------
# Вспомогательные функции
# ---------------------------------------------------------------------------
def _get_default_request() -> FactionTurnProposalRequest:
"""Возвращает запрос с дефолтными значениями."""
return {
"location_wikilink": "[[Unknown Location]]",
"factions_folder": "Фракции",
"events_folder": "События",
"game_date": date.today().strftime("%d %B %Y"),
"max_events": 5,
}
def _merge_with_defaults(req: dict) -> dict:
"""Заполняет отсутствующие поля дефолтами."""
defaults = _get_default_request()
return {**defaults, **{k: v for k, v in req.items() if v is not None}}
def _roll_initiative() -> int:
"""
Бросает инициативу по правилам WWN: 1d8.
Возвращает число 18.
"""
return random.randint(1, 8)
def _get_faction_state_label(faction_fm: dict) -> str:
"""
Возвращает читаемый статус фракции с учётом HP.
По WWN нет формальных state-меток, но добавляем для LLM:
HP 100% active
HP 5099% damaged
HP 149% critical
HP 0 defeated
"""
hp = faction_fm.get("hp", 0)
hp_max = faction_fm.get("hp_max", 1)
state = faction_fm.get("state", "active")
if state == "defeated":
return "defeated"
ratio = hp / max(hp_max, 1)
if ratio >= 1.0:
return "active"
if ratio >= 0.5:
return "damaged"
if ratio > 0:
return "critical"
return "defeated"
def _build_attack_options(
faction_fm: dict,
all_factions: list[dict],
location_wikilink: str,
) -> list[str]:
"""
Формирует список строк с вариантами атаки для данной фракции.
Для каждой пары (атакующий актив цель в локации) строит:
- формулу броска с учётом faction tags
- возможный урон
- возможный контрурон
Возвращает список Markdown-строк.
"""
loc_norm = normalize_wikilink(location_wikilink)
lines = []
# Активы атакующей фракции в локации с attack_attribute != None
own_assets = [
a for a in faction_fm.get("assets", [])
if not a.get("is_base_of_influence")
and normalize_wikilink(a.get("location", "")) == loc_norm
and not a.get("stealthed") # Stealthed активы теряют Stealth при атаке
]
for own_asset in own_assets:
asset_def = get_asset(own_asset["name"])
if not asset_def:
continue
if not asset_def.get("attack_attribute"):
continue # Актив не атакует
atk_attr = asset_def["attack_attribute"]
atk_vs = asset_def["attack_vs"]
atk_dmg = asset_def["attack_damage"]
atk_val = faction_fm.get(atk_attr.lower(), 0)
# Цели: активы и BoI других фракций в той же локации
for target_faction in all_factions:
tf_fm = target_faction.get("frontmatter", {})
tf_name = target_faction.get("name", "Unknown")
# Не атакуем себя
if normalize_wikilink(tf_fm.get("location", "")) == normalize_wikilink(
faction_fm.get("location", "____")
) and tf_name == faction_fm.get("name"):
continue
target_assets = [
a for a in tf_fm.get("assets", [])
if normalize_wikilink(a.get("location", "")) == loc_norm
]
if not target_assets:
continue
for target_asset in target_assets:
ta_name = target_asset["name"]
is_boi = target_asset.get("is_base_of_influence", False)
# Атрибут защиты
def_val = tf_fm.get(atk_vs.lower(), 0)
# Faction tags
a_extra, d_extra = get_attack_extra_die_flags(
faction_fm, tf_fm, atk_attr, asset_def.get("magic_required", "None")
)
antimagical = has_antimagical_penalty(
tf_fm, asset_def.get("magic_required", "None")
)
massive_result = check_massive_auto_win(faction_fm, tf_fm, atk_attr)
# Формула броска
a_dice = "2d10 best" if a_extra else "1d10"
d_dice = "2d10 best" if d_extra else "1d10"
if antimagical:
a_dice = "2d10 WORST (Antimagical)"
if massive_result is True:
roll_str = f"AUTO-WIN (Massive tag)"
elif massive_result is False:
roll_str = f"AUTO-LOSS (target Massive tag)"
else:
roll_str = (
f"{a_dice}+{atk_attr}({atk_val}) vs "
f"{d_dice}+{atk_vs}({def_val})"
)
# Counterattack
ta_def = get_asset(ta_name)
ctr_dmg = ta_def.get("counterattack") if ta_def else None
ctr_str = f", fail → {ctr_dmg} to {own_asset['name']}" if ctr_dmg else ", fail → no counterattack"
# BoI примечание
boi_note = " *(→ also damages faction HP directly)*" if is_boi else ""
hp_note = f"HP {target_asset.get('hp', '?')}/{target_asset.get('hp_max', '?')}"
lines.append(
f" - `{own_asset['name']}` → **{ta_name}** [{tf_name}, {hp_note}]{boi_note}\n"
f" Roll: {roll_str} | dmg: {atk_dmg}{ctr_str}"
)
return lines
def _build_hide_asset_options(faction_fm: dict, all_factions: list[dict], location_wikilink: str) -> list[str]:
"""
Формирует список активов доступных для скрытия (Hide Asset).
Условия WWN:
- Cunning >= 3
- 2 Treasure за каждый скрываемый актив
- Нельзя скрывать в локации с вражеским BoI
"""
if faction_fm.get("cunning", 0) < 3:
return []
loc_norm = normalize_wikilink(location_wikilink)
treasure = faction_fm.get("treasure", 0)
# Есть ли вражеский BoI в локации?
hostile_boi_in_loc = any(
normalize_wikilink(a.get("location", "")) == loc_norm
and a.get("is_base_of_influence")
for tf in all_factions
for a in tf.get("frontmatter", {}).get("assets", [])
if tf.get("name") != faction_fm.get("name")
)
if hostile_boi_in_loc:
return []
lines = []
for a in faction_fm.get("assets", []):
if a.get("is_base_of_influence"):
continue
if a.get("stealthed"):
continue
if normalize_wikilink(a.get("location", "")) != loc_norm:
continue
if treasure < 2:
lines.append(f" - `{a['name']}` — ⚠️ недостаточно Treasure (нужно 2, есть {treasure})")
else:
lines.append(f" - `{a['name']}` — стоимость: 2 Treasure")
return lines
def _build_move_options(faction_fm: dict, location_wikilink: str) -> list[str]:
"""
Формирует список активов доступных для перемещения (Move Asset).
Показывает текущую локацию каждого актива.
Subtle/Stealthed активы могут двигаться без ограничений.
"""
lines = []
for a in faction_fm.get("assets", []):
if a.get("is_base_of_influence"):
continue
name = a["name"]
loc = a.get("location", "?")
flags = []
if a.get("stealthed"):
flags.append("Stealthed")
if a.get("subtle"):
flags.append("Subtle")
flag_str = f" [{', '.join(flags)}]" if flags else ""
lines.append(f" - `{name}`{flag_str} currently @ {loc}")
return lines
def _build_repair_options(faction_fm: dict) -> list[str]:
"""
Формирует список повреждённых активов доступных для ремонта (Repair Asset).
"""
treasure = faction_fm.get("treasure", 0)
lines = []
for a in faction_fm.get("assets", []):
hp = a.get("hp", 0)
hp_max = a.get("hp_max", 0)
if hp >= hp_max or hp_max == 0:
continue
attr = a.get("attribute", "Force")
repair_info = calc_repair_heal(faction_fm, attr, 1)
cost = repair_info["cost"]
heal = repair_info["heal"]
can = "" if treasure >= cost else f"⚠️ нужно {cost} Treasure, есть {treasure}"
lines.append(
f" - `{a['name']}` [HP {hp}/{hp_max}] "
f"— heal {heal} HP за {cost} Treasure {can}"
)
# Ремонт самой фракции
faction_hp = faction_fm.get("hp", 0)
faction_hp_max = faction_fm.get("hp_max", 1)
if faction_hp < faction_hp_max:
fr = calc_faction_repair_heal(faction_fm)
can = "" if treasure >= fr["cost"] else f"⚠️ нужно {fr['cost']} Treasure"
lines.append(
f" - **Faction HP** [{faction_hp}/{faction_hp_max}] "
f"— heal {fr['heal']} HP за {fr['cost']} Treasure {can}"
)
return lines
def _build_create_asset_options(faction_fm: dict, location_wikilink: str) -> list[str]:
"""
Формирует список активов доступных для покупки (Create Asset).
"""
available = get_available_assets(faction_fm, location_wikilink)
if not available:
return [" - *(нет доступных активов для покупки)*"]
lines = []
for a in available:
magic_note = f", Magic {a['magic_required']}" if a["magic_required"] != "None" else ""
atk = f" | {a['attack_attribute'][0]} vs {a['attack_vs'][0]} / {a['attack_damage']}" \
if a.get("attack_attribute") else ""
ctr = f" | ctr {a['counterattack']}" if a.get("counterattack") else ""
lines.append(
f" - `{a['name']}` [{a['attribute'][0]}{a['tier_required']}{magic_note}] "
f"cost {a['cost']} Treasure, HP {a['hp_max']}{atk}{ctr}"
)
return lines
def _build_sell_options(faction_fm: dict) -> list[str]:
"""
Формирует список активов доступных для продажи (Sell Asset).
По WWN: получить floor(cost/2) Treasure если актив не повреждён,
иначе 0 Treasure.
"""
lines = []
for a in faction_fm.get("assets", []):
if a.get("is_base_of_influence"):
continue
asset_def = get_asset(a["name"])
if not asset_def:
continue
hp = a.get("hp", 0)
hp_max = a.get("hp_max", 1)
if hp < hp_max:
gain = 0
note = " (повреждён — 0 Treasure)"
else:
gain = math.floor(asset_def["cost"] / 2)
note = ""
lines.append(f" - `{a['name']}` → +{gain} Treasure{note}")
return lines
def _format_faction_section(
idx: int,
faction: dict,
initiative: int,
income_gained: int,
upkeep_info: dict,
all_factions: list[dict],
location_wikilink: str,
) -> str:
"""
Форматирует полную секцию одной фракции для вывода в propose-промпт.
Включает: статус, доход, апкип, активы в локации,
все доступные действия с числами.
"""
fm = faction.get("frontmatter", {})
name = faction.get("name", "Unknown")
state_label = _get_faction_state_label(fm)
# Статус-строка
status_str = format_faction_status(fm, name)
# Нарративные теги для LLM
dw_cat = fm.get("dw_category", "")
impulse = fm.get("impulse", "")
narrative_str = ""
if dw_cat or impulse:
narrative_str = f"\n**Нарратив:** dw_category={dw_cat} | impulse=*\"{impulse}\"*"
# Faction tags WWN
tags = fm.get("faction_tags", [])
tags_str = f"\n**WWN Tags:** {', '.join(tags)}" if tags else ""
# Цель фракции
goal = fm.get("current_goal", "нет")
xp = fm.get("xp", 0)
upgrades = get_available_upgrades(fm)
upgrade_str = ""
if upgrades:
u = upgrades[0]
upgrade_str = (
f"\n**XP:** {xp} | Можно повысить: "
f"{u['attribute']} {u['current_value']}{u['next_value']} за {u['xp_cost']} XP"
+ ("" if u["can_afford"] else " (не хватает XP)")
)
# Активы в локации
loc_norm = normalize_wikilink(location_wikilink)
assets_here = [
a for a in fm.get("assets", [])
if normalize_wikilink(a.get("location", "")) == loc_norm
]
assets_lines = []
for a in assets_here:
assets_lines.append(format_asset_line(a, is_base=a.get("is_base_of_influence", False)))
assets_str = "\n".join(assets_lines) if assets_lines else " *(нет активов в этой локации)*"
# Доход и апкип
income_str = f"+{income_gained} Treasure (было {fm.get('treasure', 0) - income_gained} → стало {fm.get('treasure', 0)})"
upkeep_str = ""
if upkeep_info["total_upkeep"] > 0:
upkeep_str = (
f"\n⚠️ **Апкип:** {upkeep_info['total_upkeep']} Treasure/ход | "
f"лишние активы: {', '.join(upkeep_info['excess_assets']) or 'нет'}"
)
# Доступные действия
attack_opts = _build_attack_options(fm, all_factions, location_wikilink)
hide_opts = _build_hide_asset_options(fm, all_factions, location_wikilink)
move_opts = _build_move_options(fm, location_wikilink)
repair_opts = _build_repair_options(fm)
create_opts = _build_create_asset_options(fm, location_wikilink)
sell_opts = _build_sell_options(fm)
# Expand Influence
treasure = fm.get("treasure", 0)
expand_str = (
f" - Создать BoI в новой локации: 1 Treasure/HP нового BoI "
f"(текущий Treasure: {treasure}, максимальный BoI HP: {treasure})\n"
f" Потребует Cunning vs Cunning vs всех фракций с BoI в целевой локации"
)
def _section(title: str, lines: list[str]) -> str:
if not lines:
return f"\n**[{title}]** *(недоступно)*"
return f"\n**[{title}]**\n" + "\n".join(lines)
return (
f"### {idx}. {name} | Initiative: {initiative} | {state_label.upper()}\n"
f"**Статы:** {status_str}"
f"{tags_str}"
f"{narrative_str}"
f"\n**Цель:** {goal}{upgrade_str}"
f"\n**Доход этого хода:** {income_str}{upkeep_str}"
f"\n\n**Активы в локации:**\n{assets_str}"
f"\n\n#### Доступные действия:"
+ _section("ATTACK", attack_opts)
+ _section("CREATE ASSET", create_opts)
+ _section("HIDE ASSET", hide_opts)
+ _section("MOVE ASSET", move_opts)
+ _section("REPAIR ASSET", repair_opts)
+ f"\n**[EXPAND INFLUENCE]**\n{expand_str}"
+ _section("SELL ASSET", sell_opts)
)
# ---------------------------------------------------------------------------
# Главная функция
# ---------------------------------------------------------------------------
def propose_faction_turn(
debug_description: str,
requests: list[FactionTurnProposalRequest],
) -> str:
"""
Основной метод, который вызывает тул с фронта.
Принимает список запросов на генерацию хода фракций.
Для каждого запроса:
- загружает все фракции из Obsidian vault через WebSocket-прокси;
- фильтрует активные фракции в указанной локации;
- бросает инициативу 1d8 для каждой фракции;
- начисляет доход по формуле WWN: ceil(W/2 + (F+C)/4);
- проверяет апкип и лимиты активов;
- для каждой фракции строит полный список механически допустимых действий;
- загружает последние события для контекста LLM;
- формирует Markdown-промпт для AI-агента.
Возвращает Markdown-строку с инструкцией для LLM.
"""
if not requests:
requests = [{}]
sections: list[str] = []
for i, raw_req in enumerate(requests, start=1):
req = _merge_with_defaults(raw_req)
location_wikilink = req["location_wikilink"]
factions_folder = req["factions_folder"]
events_folder = req["events_folder"]
game_date = req["game_date"]
max_events = req["max_events"]
# ── Загрузка данных ──────────────────────────────────────────────
try:
all_factions = get_all_factions(factions_folder)
except RuntimeError as e:
sections.append(f"## ❌ Ошибка загрузки фракций\n{e}")
continue
active_in_loc = [
f for f in get_factions_in_location(all_factions, location_wikilink)
if f.get("frontmatter", {}).get("state") != "defeated"
]
if not active_in_loc:
sections.append(
f"## Ход фракций: {location_wikilink} | {game_date}\n\n"
f"Активных фракций в локации не найдено."
)
continue
# ── Последние события ────────────────────────────────────────────
# Собираем все tags_listen активных фракций
all_listen_tags: list[str] = []
for f in active_in_loc:
all_listen_tags.extend(f.get("frontmatter", {}).get("tags_listen", []))
all_listen_tags = list(set(all_listen_tags))
try:
recent_events = get_recent_events(events_folder, all_listen_tags, max_events)
except RuntimeError:
recent_events = []
# ── Инициатива и порядок ─────────────────────────────────────────
factions_with_init = []
for faction in active_in_loc:
init = _roll_initiative()
factions_with_init.append((init, faction))
factions_with_init.sort(key=lambda x: x[0], reverse=True)
# ── Начисление дохода и апкипа ───────────────────────────────────
# NOTE: Изменения Treasure применяются к копии frontmatter для отображения.
# Реальная запись в файл происходит в resolve_faction_turn.
for init, faction in factions_with_init:
fm = faction.get("frontmatter", {})
income = calc_income(
fm.get("wealth", 0),
fm.get("force", 0),
fm.get("cunning", 0),
)
fm["treasure"] = fm.get("treasure", 0) + income
# Апкип
upkeep = calc_asset_upkeep(fm)
fm["treasure"] = max(0, fm["treasure"] - upkeep["total_upkeep"])
faction["_income"] = income
faction["_upkeep"] = upkeep
# ── Сборка вывода ────────────────────────────────────────────────
header = (
f"# Ход фракций: {location_wikilink} | {game_date}\n\n"
)
# Последние события
events_block = "## Последние события (контекст для LLM)\n"
if recent_events:
for ev in recent_events:
ev_name = ev.get("name", "?")
ev_actor = ev.get("frontmatter", {}).get("actor", "?")
ev_action = ev.get("frontmatter", {}).get("action_type", "?")
events_block += f"- [[{ev_name}]]: {ev_actor}{ev_action}\n"
else:
events_block += "- *(событий не найдено)*\n"
# Секции фракций
faction_sections = []
for idx, (init, faction) in enumerate(factions_with_init, start=1):
fm = faction.get("frontmatter", {})
section = _format_faction_section(
idx=idx,
faction=faction,
initiative=init,
income_gained=faction.get("_income", 0),
upkeep_info=faction.get("_upkeep", {"total_upkeep": 0, "excess_assets": []}),
all_factions=all_factions,
location_wikilink=location_wikilink,
)
faction_sections.append(section)
# Инструкция для LLM
llm_instruction = (
"\n---\n\n"
"## ИНСТРУКЦИЯ ДЛЯ LLM\n\n"
"Ты — опытный Мастер Игры TTRPG, ведущий живой мир.\n"
"Для каждой фракции со статусом ACTIVE, DAMAGED или CRITICAL:\n\n"
"1. Выбери **одно действие** из предложенного списка. "
"Обоснуй через impulse и последние события (12 предложения).\n"
"2. Если выбрано ATTACK — укажи точную пару (актив → цель) из списка.\n"
"3. Если выбрано CREATE ASSET — укажи точное название актива из списка.\n"
"4. Если выбрано EXPAND INFLUENCE — укажи целевую локацию и HP нового BoI.\n"
"5. Если выбрано HIDE ASSET — укажи какой актив скрывается.\n"
"6. Если выбрано MOVE ASSET — укажи актив и destination-локацию.\n"
"7. В конце добавь блок **'ДЕЙСТВИЯ ПО УМОЛЧАНИЮ'** — самый логичный выбор "
"одной строкой для каждой фракции.\n\n"
"**НЕ МЕНЯЙ** механические данные (броски, урон, стоимости).\n"
"**НЕ ВЫДУМЫВАЙ** действия которых нет в списке.\n"
"После выбора ГМ вызовет `resolve_faction_turn` с конкретными параметрами."
)
full_section = (
header
+ events_block
+ "\n---\n\n"
+ "\n\n---\n\n".join(faction_sections)
+ llm_instruction
)
sections.append(full_section)
return "\n\n---\n\n".join(sections)
# ---------------------------------------------------------------------------
# Точка входа для тестирования
# ---------------------------------------------------------------------------
if __name__ == "__main__":
result = propose_faction_turn(
"тест propose_faction_turn",
[
{
"location_wikilink": "[[Waterdeep]]",
"factions_folder": "Фракции",
"events_folder": "События",
"game_date": "15 Mirtul 1492 DR",
"max_events": 5,
},
{
"location_wikilink": "[[Neverwinter]]",
# остальные поля → дефолты
},
],
)
print(result)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,499 @@
"""
wwn_mechanics.py механика системы фракций WWN (Worlds Without Number).
Содержит все расчётные формулы и таблицы из книги:
- Расчёт hp_max фракции по атрибутам
- Расчёт дохода за ход (Treasure income)
- Таблица стоимости повышения атрибутов (XP)
- Проверка лимитов активов
- Расчёт апкипа за превышение лимитов
- Логика faction goals (цели фракций)
- Вспомогательные функции для propose и resolve тулов
"""
import math
from typing import Optional
from wwn_assets_data import MAGIC_LEVELS, get_asset
# ---------------------------------------------------------------------------
# Таблицы WWN (стр. 327)
# ---------------------------------------------------------------------------
# hp_value[attribute_rating] = HP contribution
HP_VALUE: dict[int, int] = {
1: 1,
2: 2,
3: 4,
4: 6,
5: 9,
6: 12,
7: 16,
8: 20,
}
# XP cost to raise attribute FROM current level TO current+1
# Key = текущее значение атрибута, Value = стоимость повышения до следующего
XP_COST_TO_RAISE: dict[int, int] = {
1: 2,
2: 4,
3: 6,
4: 9,
5: 12,
6: 16,
7: 20,
}
# ---------------------------------------------------------------------------
# Расчёт HP фракции
# ---------------------------------------------------------------------------
def calc_hp_max(force: int, wealth: int, cunning: int) -> int:
"""
Считает максимальные HP фракции по таблице WWN (стр. 327).
HP = hp_value(Force) + hp_value(Wealth) + hp_value(Cunning)
Примеры:
Force 2, Wealth 4, Cunning 5 2 + 6 + 9 = 17
Force 3, Wealth 3, Cunning 3 4 + 4 + 4 = 12
"""
return (
HP_VALUE.get(force, 0)
+ HP_VALUE.get(wealth, 0)
+ HP_VALUE.get(cunning, 0)
)
# ---------------------------------------------------------------------------
# Расчёт дохода
# ---------------------------------------------------------------------------
def calc_income(wealth: int, force: int, cunning: int) -> int:
"""
Считает Treasure income за один faction turn (стр. 324 WWN).
Income = ceil(Wealth/2 + (Force + Cunning)/4)
Примеры:
Wealth 4, Force 2, Cunning 5 ceil(2 + 1.75) = 4
Wealth 2, Force 1, Cunning 1 ceil(1 + 0.5) = 2
"""
return math.ceil(wealth / 2 + (force + cunning) / 4)
# ---------------------------------------------------------------------------
# Апкип активов
# ---------------------------------------------------------------------------
def calc_asset_upkeep(faction_fm: dict) -> dict:
"""
Рассчитывает апкип фракции за превышение лимитов активов (стр. 325 WWN).
Правило: фракция не может иметь больше Force/Wealth/Cunning активов
чем её соответствующий атрибут. За каждый лишний актив платится 1 Treasure/ход.
Также считает обязательный апкип для Free Company (1 Treasure) и
Hired Legion (2 Treasure).
Возвращает словарь:
{
"excess_cunning": int, количество лишних Cunning-активов
"excess_force": int,
"excess_wealth": int,
"free_company_count": int,
"hired_legion_count": int,
"total_upkeep": int, суммарный Treasure к оплате
"excess_assets": list[str] имена лишних активов (для уведомления LLM)
}
"""
owned = faction_fm.get("assets", [])
# Считаем активы по атрибутам (BoI не считается в лимит)
attr_counts: dict[str, list[str]] = {"Cunning": [], "Force": [], "Wealth": []}
free_company_count = 0
hired_legion_count = 0
for a in owned:
if a.get("is_base_of_influence"):
continue
attr = a.get("attribute")
if attr in attr_counts:
attr_counts[attr].append(a["name"])
if a["name"] == "Free Company":
free_company_count += 1
if a["name"] == "Hired Legion":
hired_legion_count += 1
# Лимиты
limits = {
"Cunning": faction_fm.get("cunning", 0),
"Force": faction_fm.get("force", 0),
"Wealth": faction_fm.get("wealth", 0),
}
excess_assets: list[str] = []
excess: dict[str, int] = {}
for attr, asset_names in attr_counts.items():
over = max(0, len(asset_names) - limits[attr])
excess[attr] = over
if over > 0:
# Лишние — последние в списке (произвольный порядок)
excess_assets.extend(asset_names[-over:])
total_upkeep = (
excess["Cunning"]
+ excess["Force"]
+ excess["Wealth"]
+ free_company_count * 1
+ hired_legion_count * 2
)
return {
"excess_cunning": excess["Cunning"],
"excess_force": excess["Force"],
"excess_wealth": excess["Wealth"],
"free_company_count": free_company_count,
"hired_legion_count": hired_legion_count,
"total_upkeep": total_upkeep,
"excess_assets": excess_assets,
}
# ---------------------------------------------------------------------------
# Repair Asset
# ---------------------------------------------------------------------------
def calc_repair_heal(faction_fm: dict, asset_attribute: str, application_number: int) -> dict:
"""
Считает параметры одного применения Repair Asset (стр. 326 WWN).
Правило:
1-е применение на актив: cost=1 Treasure, heal=ceil(attr/2)
2-е применение на тот же актив: cost=2 Treasure, heal=ceil(attr/2)
3-е: cost=3 и т.д.
asset_attribute "Force" | "Wealth" | "Cunning"
application_number порядковый номер применения на этот актив за ход (1, 2, 3...)
Возвращает {"cost": int, "heal": int}
"""
attr_value = faction_fm.get(asset_attribute.lower(), 0)
heal = math.ceil(attr_value / 2)
return {"cost": application_number, "heal": heal}
def calc_faction_repair_heal(faction_fm: dict) -> dict:
"""
Считает параметры починки HP самой фракции (стр. 326 WWN).
Правило: один раз за ход, 1 Treasure,
лечит ceil((highest_attr + lowest_attr) / 2).
Возвращает {"cost": 1, "heal": int, "highest_attr": str, "lowest_attr": str}
"""
attrs = {
"force": faction_fm.get("force", 0),
"wealth": faction_fm.get("wealth", 0),
"cunning": faction_fm.get("cunning", 0),
}
highest = max(attrs, key=lambda k: attrs[k])
lowest = min(attrs, key=lambda k: attrs[k])
heal = math.ceil((attrs[highest] + attrs[lowest]) / 2)
return {
"cost": 1,
"heal": heal,
"highest_attr": highest,
"lowest_attr": lowest,
}
# ---------------------------------------------------------------------------
# Expand Influence
# ---------------------------------------------------------------------------
def calc_expand_influence_cost(desired_hp: int) -> int:
"""
Считает стоимость создания нового Base of Influence (стр. 326 WWN).
Правило: 1 Treasure за каждый HP нового BoI.
desired_hp желаемый hp_max нового BoI.
"""
return desired_hp
# ---------------------------------------------------------------------------
# Faction Goals и XP
# ---------------------------------------------------------------------------
def calc_xp_cost_to_raise(current_value: int) -> Optional[int]:
"""
Возвращает стоимость в XP для повышения атрибута с current_value до current_value+1.
Возвращает None если атрибут уже на максимуме (8).
Таблица из WWN стр. 327:
12: 2 XP
23: 4 XP
34: 6 XP
45: 9 XP
56: 12 XP
67: 16 XP
78: 20 XP
"""
if current_value >= 8:
return None
return XP_COST_TO_RAISE.get(current_value)
def get_available_upgrades(faction_fm: dict) -> list[dict]:
"""
Возвращает список атрибутов которые фракция может повысить прямо сейчас.
Каждый элемент:
{
"attribute": "cunning",
"current_value": 5,
"next_value": 6,
"xp_cost": 12,
"current_xp": 15,
"can_afford": True
}
"""
current_xp = faction_fm.get("xp", 0)
result = []
for attr in ("force", "wealth", "cunning"):
current = faction_fm.get(attr, 0)
cost = calc_xp_cost_to_raise(current)
if cost is None:
continue
result.append({
"attribute": attr,
"current_value": current,
"next_value": current + 1,
"xp_cost": cost,
"current_xp": current_xp,
"can_afford": current_xp >= cost,
})
return result
# ---------------------------------------------------------------------------
# Faction Tags — extra die flags
# ---------------------------------------------------------------------------
def get_attack_extra_die_flags(
attacker_fm: dict,
defender_fm: dict,
attack_attribute: str,
asset_magic_required: str = "None",
) -> tuple[bool, bool]:
"""
Определяет флаги extra die для attribute check на основе faction tags (стр. 325 WWN).
WWN-правила:
Machiavellian extra die для всех Cunning checks (Cunning должен быть высшим)
Martial extra die для всех Force checks
Rich extra die для всех Wealth checks
Antimagical (у защитника) атаки с Medium+ Magic: атакующий берёт ХУДШИЙ из 2d10
Zealot (у атакующего) при провале может перебросить, но получает counterattack
Возвращает (attacker_extra_die: bool, defender_extra_die: bool).
Antimagical кодируется как defender_extra_die=True И отдельно помечается
флагом antimagical_penalty в вызывающем коде.
"""
def has_tag(fm: dict, tag: str) -> bool:
return tag in fm.get("faction_tags", [])
attacker_extra = False
defender_extra = False
if attack_attribute == "Cunning":
if has_tag(attacker_fm, "Machiavellian"):
attacker_extra = True
if has_tag(defender_fm, "Machiavellian"):
defender_extra = True
elif attack_attribute == "Force":
if has_tag(attacker_fm, "Martial"):
attacker_extra = True
if has_tag(defender_fm, "Martial"):
defender_extra = True
elif attack_attribute == "Wealth":
if has_tag(attacker_fm, "Rich"):
attacker_extra = True
if has_tag(defender_fm, "Rich"):
defender_extra = True
# Antimagical: если у защитника есть тег и атака требует Medium+ magic,
# атакующий бросает с ХУДШИМ результатом (моделируется как penalty, не bonus)
# Возвращаем как отдельный флаг в расширенной версии
return attacker_extra, defender_extra
def has_antimagical_penalty(defender_fm: dict, asset_magic_required: str) -> bool:
"""
Проверяет применяется ли штраф Antimagical тега (стр. 325 WWN).
Antimagical: Assets that require Medium or higher Magic roll all attribute
checks twice against this faction during an Attack and take the worst roll.
"""
if "Antimagical" not in defender_fm.get("faction_tags", []):
return False
return MAGIC_LEVELS.get(asset_magic_required, 0) >= MAGIC_LEVELS["Medium"]
# ---------------------------------------------------------------------------
# Massive tag
# ---------------------------------------------------------------------------
def check_massive_auto_win(
attacker_fm: dict,
defender_fm: dict,
attribute: str,
) -> Optional[bool]:
"""
Проверяет автоматическую победу по тегу Massive (стр. 325 WWN).
Massive: faction automatically wins attribute checks if its attribute
is more than twice as big as the opposing side's, unless the other side
is also Massive.
Возвращает:
True атакующий автоматически побеждает
False защитник автоматически побеждает
None броски нужны (нет автопобеды)
"""
def has_massive(fm: dict) -> bool:
return "Massive" in fm.get("faction_tags", [])
a_val = attacker_fm.get(attribute.lower(), 0)
d_val = defender_fm.get(attribute.lower(), 0)
a_massive = has_massive(attacker_fm)
d_massive = has_massive(defender_fm)
if a_massive and not d_massive and a_val > d_val * 2:
return True
if d_massive and not a_massive and d_val > a_val * 2:
return False
return None
# ---------------------------------------------------------------------------
# Форматирование для вывода в Markdown
# ---------------------------------------------------------------------------
def format_faction_status(faction_fm: dict, faction_name: str) -> str:
"""
Форматирует статус фракции в одну строку для вывода в propose-промпте.
Пример:
"Force 2 | Wealth 4 | Cunning 5 | Magic: Medium | Treasure: 10 | HP: 17/17"
"""
locs_list = faction_fm.get("locations", [])
locs_str = ", ".join(locs_list) if locs_list else "нет влияния"
return (
f"Force {faction_fm.get('force', '?')} | "
f"Wealth {faction_fm.get('wealth', '?')} | "
f"Cunning {faction_fm.get('cunning', '?')} | "
f"Magic: {faction_fm.get('magic', 'None')} | "
f"Treasure: {faction_fm.get('treasure', 0)} | "
f"HP: {faction_fm.get('hp', '?')}/{faction_fm.get('hp_max', '?')}"
f"Locs: [{locs_str}]"
)
def format_asset_line(asset: dict, is_base: bool = False) -> str:
"""
Форматирует одну строку описания актива для вывода в propose-промпте.
Пример для обычного актива:
" - Organization Moles [C5, HP 10/10, Stealthed ✓] C vs C / 2d6 dmg"
Пример для BoI:
" - BoI [[Waterdeep]] [HP 17/17]"
"""
if is_base:
hp = asset.get("hp", "?")
hp_max = asset.get("hp_max", "?")
loc = asset.get("location", "?")
return f" - **BoI** {loc} [HP {hp}/{hp_max}]"
name = asset.get("name", "?")
attr = asset.get("attribute", "?")[0] # C/F/W
tier = asset.get("tier_required", "?")
hp = asset.get("hp", "?")
hp_max = asset.get("hp_max", "?")
stealthed = " 🥷Stealthed" if asset.get("stealthed") else ""
loc = asset.get("location", "")
loc_str = f" @ {loc}" if loc else ""
# Атака
atk_attr = asset.get("attack_attribute", "")
atk_vs = asset.get("attack_vs", "")
atk_dmg = asset.get("attack_damage", "")
if atk_attr and atk_vs and atk_dmg:
atk_str = f" | {atk_attr[0]} vs {atk_vs[0]} / {atk_dmg} dmg"
else:
atk_str = " | no attack"
ctr = asset.get("counterattack", "")
ctr_str = f" | ctr {ctr}" if ctr else ""
return (
f" - **{name}** [{attr}{tier}, HP {hp}/{hp_max}{stealthed}]"
f"{atk_str}{ctr_str}{loc_str}"
)
# ---------------------------------------------------------------------------
# Тест
# ---------------------------------------------------------------------------
if __name__ == "__main__":
print("=== Тест wwn_mechanics.py ===\n")
print("--- calc_hp_max ---")
cases = [(2, 4, 5), (3, 3, 3), (7, 6, 5), (1, 1, 1), (8, 8, 8)]
for f, w, c in cases:
hp = calc_hp_max(f, w, c)
print(f" Force {f}, Wealth {w}, Cunning {c} → HP max {hp}")
print("\n--- calc_income ---")
for w, f, c in [(4, 2, 5), (2, 1, 1), (8, 7, 6)]:
inc = calc_income(w, f, c)
print(f" Wealth {w}, Force {f}, Cunning {c} → Income {inc}")
print("\n--- calc_xp_cost_to_raise ---")
for v in range(1, 9):
cost = calc_xp_cost_to_raise(v)
print(f" Raise from {v}{v+1}: {cost} XP")
print("\n--- calc_repair_heal ---")
test_fm = {"force": 4, "wealth": 3, "cunning": 5}
for attr, app in [("Force", 1), ("Force", 2), ("Cunning", 1)]:
r = calc_repair_heal(test_fm, attr, app)
print(f" Repair {attr} (application #{app}): cost={r['cost']} Treasure, heal={r['heal']} HP")
print("\n--- calc_asset_upkeep ---")
fm_over = {
"force": 2, "wealth": 3, "cunning": 2,
"assets": [
{"name": "Infantry", "attribute": "Force"},
{"name": "Cavalry", "attribute": "Force"},
{"name": "Knights", "attribute": "Force"}, # лишний
{"name": "Front Merchant", "attribute": "Wealth"},
{"name": "Farmers", "attribute": "Wealth"},
{"name": "Free Company", "attribute": "Wealth"},
{"name": "Smugglers", "attribute": "Cunning"},
{"name": "Spymaster", "attribute": "Cunning"},
{"name": "Blackmail", "attribute": "Cunning"}, # лишний
]
}
upkeep = calc_asset_upkeep(fm_over)
print(f" Excess: {upkeep}")
print("\n=== Готово ===")

View File

@ -0,0 +1,242 @@
Elemental Plane of Air
The Elemental Plane of Air was an Inner Plane[9] or Elemental Plane[14] of the Great Wheel cosmology and the World Tree cosmology models. After the Spellplague, the Elemental Plane of Air collapsed into the Elemental Chaos, mixing with all the other Inner Planes.[15] Air is one of the four elements and two energies that make up the known universe and therefore of keen interest to cosmologists as well as spell casters that wish to harness and wield the raw power of the elements.[16]
1 Cosmology
2 Description
2.1 Notable Locations
3 Inhabitants
4 Realms
5 Appendix
5.1 Notes
5.2 Appearances
5.2.1 Adventures
5.2.2 Novels & Short Stories
5.2.3 Video Games
5.2.4 Board Games
5.2.5 Organized Play & Licensed Adventures
5.3 Further Reading
5.4 External Links
5.5 References
5.6 Connections
Cosmology[]
According to the Great Wheel cosmology model, the Elemental Plane of Air could be reached via the Ethereal Plane, an adjacent elemental plane, or by an elemental vortex.[9] If traveling through the Deep Ethereal, a blue curtain of vaporous color indicated the boundary of the Plane of Air's Border Ethereal region. Once in the Border Ethereal, a traveler could observe the Plane of Air and be detected by its denizens.[17] Using the spherical model, this plane was adjacent to the para-elemental planes of Ice and Smoke and the quasi-elemental planes of Lightning and Vacuum.[10] Elemental vortices could occur wherever a high concentration or nearly pure form of an element was found, and could be temporary or permanent. A vortex to the Plane of Air could manifest in the eye of a hurricane or in the clean, crisp air atop a high mountain, for example.[3] There was a vortex to the Elemental Plane of Water called the Waterspout not far from the djinn city/palace called the Citadel of Ice and Steel.[18] Temporary gates could be created by the plane shift [19] spell or the abilities of high level druids.[9][20]
In the World Tree cosmology model, the Astral Plane connected all planes with the Prime Material Plane and the Ethereal Plane was only used for journeying between locations on the Prime.[21] The Elemental Plane of Air was not connected or coterminous with any other elemental plane.[22] The spell astral projection [23][24] could be used to reach the Plane of Air via a pale blue color pool.[25] Additionally, the gate [26][27] and plane shift [23][28] spells could be used to open a temporary portal to this plane. To reach the plane in this manner required a tin fork tuned to the note of A as a spell focus.[29]
Description[]
The Elemental Plane of Air was filled essentially completely with air but had various impurities that tended to form pockets or bubbles in the otherwise pure atmosphere.[30] Gaseous bubbles included clouds of every type, fog, steam, mist, smoke, poisonous clouds and acidic vapors; also the rare intrusion of elemental fire which is flame without fuel. Liquid impurities were usually water or water-based and tended to form floating spheres when not buffeted or frozen by the winds.[18] Solid matter could be found here, from dust, ash, salt, or sand, to chunks of earth approaching the size of a large asteroid. The larger chunks were often brought into the plane by intelligent beings and were very likely to be inhabited or formerly inhabited.[18][31] As described by the Great Wheel model, a traveler with a guide could approach the boundaries with the para- and quasi-elemental planes: where the whiff of smoke eventually became hot, thick, and choking, or the tang of ozone soon lead to heavy storms with arcs of lightning in all directions, or the temperature dropped until flakes of snow, crystals of ice, and lumps of hail finally became a wall of ice, or the light faded to gray and the air thinned out until there was nothing.[18][32]
If you had to describe the Elemental Plane of Air in a single word, it would have been "blue". The very substance of the plane seemed to radiate the magnificent sapphire hue of a clear summer day on the Prime Material Plane.[30] Visibility was twice what the best conditions on the Prime could allow unless, of course, something obscured vision.[33] Weather was the primary natural hazard in this plane. The winds were normally light to moderately strong throughout the plane but could intensify into tornadoes, maelstroms, and hurricanes with powerful lightning. These extreme weather events were common,[34] and when other elements got caught up in the storm, it could produce pounding rain, blinding snow, pelting hail, freezing sleet, and storms of choking smoke, biting sand, burning ash, scalding steam, or searing fire.[31] The worst of these was the maelstrom, a toroid-shaped tornado that could last for decades.[18][35] Being caught in one was like being in a violent dust storm and death was only a matter of minutes away unless the victim was able to achieve great speed (escape velocity),[35] perform an act of great strength[18] or receive outside assistance.[35][18] Spellcasting was impossible within a maelstrom.[18]
Notable Locations[]
Borealis[18]
Citadel of Ice and Steel[18]
Taifun, Palace of Tempests[18]
Inhabitants[]
A and inhabited island in the Plane of Air.
A djinni and inhabited island in the Plane of Air.
Invisible, and yet the most common denizens of this plane were the elementals themselves[3][36] including aerial servants,[37] invisible stalkers[1][2][38] and wind walkers.[39] Unless magic was employed their outlines were only vaguely visible if dust or debris were caught in their bodies. When made visible, air elementals were usually large wispy beings with aerodynamic bodies and wings, or propelled themselves by forcing air through their bodies and using fins for maneuvering.[2] Others, sometimes called animentals,[36] mimicked the shape of Prime Material animals and monsters.[3]
Immigrants[note 1] to the Elemental Plane of Air included the djinn,[1][2][34][40] some jann,[2][41] mephits of the air, dust and ice subtypes,[3] and arrowhawks.[1][3] Reported sightings of other creatures included beholders,[3][42] belkers,[1][43] cloud giants,[43][44] devas,[2][45] hippogriffs,[3][46] ildriss,[47] pegasi,[3][48] skriaxit,[18] spectres,[2][49] sphinxes,[2][3][49] sprites,[3][50] sylphs,[18] and vapor rats.[2][51]
---
Elemental Plane of Earth
The Elemental Plane of Earth was an Inner Plane[10] or Elemental Plane[16] of the Great Wheel cosmology and the World Tree cosmology models. After the Spellplague, the Elemental Plane of Earth collapsed into the Elemental Chaos, mixing with all the other Inner Planes.[17] Earth is one of the four elements and two energies that make up the known universe and therefore of keen interest to cosmologists.[18] The abundance of large gems and huge ore deposits lured many greedy prospectors to brave the significant dangers of this plane.[2]
1 Cosmology
2 Description
2.1 Notable Locations
2.2 Divine Realms
3 Inhabitants
4 Appendix
4.1 Appearances
4.2 Notes
4.3 References
4.4 Connections
Cosmology[]
According to the Great Wheel cosmology model, the Elemental Plane of Earth could be reached via the Ethereal Plane, an adjacent elemental plane, or by an elemental vortex.[10] A vortex called the Pale River led to the Elemental Plane of Water and a vortex called the Iron Crucible connected to the Elemental Plane of Fire.[20] If traveling through the Deep Ethereal, a brown curtain of vaporous color indicated the boundary of the Plane of Earth's Border Ethereal region. Once in the Border Ethereal, a traveler could observe the Plane of Earth and be detected by its denizens.[21] Using the spherical model, this plane was adjacent to the para-elemental planes of Ooze and Magma and the quasi-elemental planes of Minerals and Dust.[11] Elemental vortices could occur wherever a high concentration or nearly pure form of an element was found, and could be temporary or permanent. Vortices to the Plane of Earth were nearly always found in the heart of mountain ranges, particularly young ones.[22] Temporary gates could be created by the plane shift [23] spell or the abilities of high level druids.[10][24]
In the World Tree cosmology model, the Astral Plane connected all planes with the Prime Material Plane and the Ethereal Plane was only used for journeying between locations on the Prime.[25] The Elemental Plane of Earth was not connected or coterminous with any other elemental plane.[26] The spell astral projection [27][28] could be used to reach the Plane of Earth via a moss granite color pool.[29] Additionally, the gate [30][31] and plane shift [27][32] spells could be used to open a temporary portal to this plane, but could be extremely dangerous unless the destination was previously scouted. To reach the Plane of Earth by means of a plane shift spell required a zinc fork tuned to the note of A.[33]
An unusual ability granted by Moradin to his ardent followers, the Hammers of Moradin, allowed them to use the Earth Plane as a transitive plane much like the shadow walk spell allowed spellcasters to skirt the edge of the Shadow Plane and quickly travel between locations on the Prime Plane.[34]
Description[]
The Elemental Plane of Earth was an infinite expanse of solid matter pockmarked by bubbles of other elements and riddled with fissures and tunnels created by burrowing creatures or the occasional small mining operation.[22][36] Ensconced in a few of these pockets were trading outposts and the rare hidden wizard fortress.[20] Solid does not imply stationary: the substances of this plane were constantly moving in a slow, grinding motion punctuated by earthquakes from small tremors to massively violent upheavals.[4][37] Open spaces were gradually filled by the relentless shifting[20][36] (or marauding earth elementals)[15] unless action was taken to prevent it. Air could be found in scattered pockets but unbreathable gasses were also present—unprepared travelers lucky enough to arrive in a cavern might slowly asphyxiate while the unlucky quickly suffocated by being buried alive.[36][37] Other pockets of magma, water, ooze, dust, or ash were particularly dangerous for miners if they accidentally breached one of these.[20] No light existed in the Plane of Earth except for rare luminous gems buried in the crushing darkness. Travelers able to pass through stone were effectively blind unless they used magic such as a ring of x-ray vision,[38][39][40] or until their eyes reached an open space where darkvision could operate or a source of light could be produced.[36] Hearing was actually enhanced while encased in earth, to the point where travelers could detect any movement through the rock within a certain radius of their position.[37]
As described by the Great Wheel model, the solid earth changed in proximity to the para- and quasi-elemental planes. A native guide was necessary to find these border regions and each had their own dangers. Near the Plane of Ooze, more water was present and the rock gradually lost cohesion. Approaching the Plane of Magma the temperature rose until the rock glowed with heat and became viscous. Proximity to the Negative Material plane dessicated the earth and caused it to crumble to dust. Toward the Positive Material plane, veins of ore, crystal, and gems became richer and more prevalent, finally crossing the border to the quasi-elemental plane of Minerals.[41]
Every type of rock, soil, mineral, metal ore, sand, and dirt could be found here in abundance, ranging from talc soft to marble tough to diamond hard. Mining operations tended to be small and short-lived because the movement of the substance of the plane and intense gravity caused cave-ins, and the native population defended their territory and/or food supply.[22] In addition, there was the unusual problem of where to put the discarded mine tailings.[37] The dao were the only people known to successfully manage large continuous mining operations in the Plane of Earth. They did this in the Great Dismal Delve mercilessly using slave labor to dig and repair earthquake damage.[2][4][42]
Notable Locations[]
The Great Dismal Delve[2][4][20][42]
The Sevenfold Mazework[43]
The Hidden Fulcrum[44]
The Pale River[45]
The Iron Crucible[45]
The Aviary was a gigantic air-filled cavern inhabitated by a colony of avariel; due to its effective lack of gravity it was a paradise for all who enjoyed flying.[46]
Divine Realms[]
Geb of the Mulhorandi pantheon had a realm here called the Caverns under the Stars.[41][47][46]
Grumbar, the Lord of the Earth, Gnarly One, King of the Land Below the Roots, Boss of Earth Elementals,[48][49][50][51][52] was thought to be a mountain-sized earth elemental with god-like powers that rarely granted audiences.[42] His realm was called the Great Mountain.[53] After the Spellplague it was revealed that he was a primordial and he formed a new realm called Root Hold in the Elemental Chaos.[17]
Kabril Ali al-Sara al-Zalazil, Great Khan of the Dao, the Fountain of Wealth, the Perfect Compass, Atamen of the Mountain's Roots, and other titles lived in the Great Dismal Delve in a palace called the Sevenfold Mazework.[20]
Ogremoch, Prince of Evil Earth Creatures,[54] had a citadel called Stonemire[55] atop a large mesa within a huge pocket of vacuum[42] near the paraplane of Magma.[55]
Sunnis, Princess of Good Earth Creatures, resided in a fortress called Sandfall.[55]
Inhabitants[]
stalked by in the Plane of Earth.
Adventurers stalked by xorn in the Plane of Earth.
The matter of the plane itself came to life, with earth elementals being the most numerous creatures on the plane,[2][3][4] with typically blocky, angular bodies covered in sharp edges, ranging from man-sized to mountainous.[42] Other native creatures looked like rough-hewn statues of Prime Material Plane animals and monsters or animated crystalline forms[3] like crysmals.[56] Then there were the alien-looking khargra,[3][57] xorn,[3][4][58] and xaren[3][59] with tri-fold symmetric bodies and a taste for metal. All natives and some creatures with an earth-affinity could move through the plane like a fish through water, the rock flowing around them and closing back up again, leaving no tunnel.[15][note 1]
Immigrants and visitors to the Elemental Plane of Earth included basilisks (greater),[3][60] chaggrin,[61] the dao,[2][3][4][62] dwarves,[15][63] an occasional blue[4][64] or copper dragon,[4][65] galeb duhr,[20] gargoyles,[15][66] janni,[4][67] lava children,[3][68] mephits of the earth and salt subtypes,[4] the pech,[3][69] sandlings,[3][70] stone giants,[4][71] thoqqua,[4][72] and wraiths.[3][58]
---
Elemental Plane of Fire
FA-symbol
Elemental Plane of Fire is a Featured Article! It is one of the best articles created by the Forgotten Realms Wiki community.
However, if you can update it or think of a way to further improve it, then please feel free to contribute.
The Elemental Plane of Fire was an Inner Plane[9] or Elemental Plane[15] of the Great Wheel cosmology and the World Tree cosmology models. After the Spellplague, the Elemental Plane of Fire collapsed into the Elemental Chaos, mixing with all the other Inner Planes.[16] Fire is one of the four elements and two energies that make up the known universe[17] and more than any other element has fascinated sentient beings since the beginning of time. The flickering of a candle, the spark of a flint and steel, or the dying embers of a campfire, all have the potential to grow and engulf the world in flame—can a drop of water, a breath of air, or a mote of dust do the same? Elemental fire is pure flame that does not require air or fuel to burn and can take on a solid, liquid, or gaseous state, yet it will ignite and consume anything flammable and unprotected from fire.[14]
1 Cosmology
2 Description
2.1 City of Brass
2.2 The Elemental Foundation of Fire
3 Inhabitants
4 History
5 Realms
6 Connections
7 Appendix
7.1 Notes
7.2 Appearances
7.2.1 Adventures
7.2.2 Novels & Short Stories
7.2.3 Video Games
7.2.4 Organized Play & Licensed Adventures
7.3 External links
7.4 References
7.5 Connections
Cosmology[]
According to the Great Wheel cosmology model, the Elemental Plane of Fire could be reached via the Ethereal Plane, an adjacent elemental plane, or by an elemental vortex.[9] Two known vortices to adjacent elemental planes were the Iron Crucible that led to the Elemental Plane of Earth[18] and a vortex to the Plane of Air atop Jabal Turab, the Mount of Dust.[19] If traveling through the Deep Ethereal, a red curtain of vaporous color indicated the boundary of the Plane of Fire's Border Ethereal region. Once in the Border Ethereal, a traveler could observe the Plane of Fire and be detected by its denizens.[4] Using the spherical model, this plane was adjacent to the para-elemental planes of Smoke and Magma and the quasi-elemental planes of Radiance and Ash.[10] Elemental vortices could occur wherever a high concentration or nearly pure form of an element was found, and could be temporary or permanent. Vortices to the Plane of Fire could often be found in pools of molten lava or the upwelling of magma in active volcanoes.[20] Temporary gates could be created by the plane shift [21] spell or the abilities of high-level druids.[9][22]
As described by the World Tree cosmology model, the Astral Plane connected all planes with the Prime Material Plane and the Ethereal Plane was only used for journeying between locations on the Prime.[23] The Elemental Plane of Fire was not connected or coterminous with any other elemental plane.[24] The spell astral projection [25][26] could be used to reach the Plane of Fire via a fire opal color pool.[27][note 1] Additionally, the gate [28][29] and plane shift [25][30] spells could be used to open a temporary portal to this plane. To reach the Plane of Fire by means of a plane shift spell required a copper fork tuned to the note of A.[31]
Description[]
Arriving on the Plane of Fire was like stepping into the flaming maw of an ancient red dragon; if one didn't have protection or immunity from temperatures high enough to melt stone then death was swift.[14][32] The following discussion assumes a visitor and all their clothing and gear had this capability and either did not need to breathe or could compensate for a superheated, often toxic atmosphere that could immolate one from the inside[32][33] (think cloudkill plus incendiary cloud). In general, the more fluid the elemental fire, the hotter it was and the more damage it did to unprotected material.[3] Permanent physical structures were very rare.[32][33]
Efreet and the City of Brass in the Plane of Fire
Efreet and the City of Brass in the Plane of Fire
Unlike the other three elemental planes, the Plane of Fire had normal gravity and a landscape, although most of the "ground" was made primarily of loosely packed elemental fire and felt like walking in a swamp of hot coals.[33] The rivers and oceans were filled with a more liquid version of the same stuff and swimming worked normally as a mode of transportation.[32] Non-native flying creatures found the atmosphere thin and therefore did not have their usual speed or maneuverability. Visibility was hampered by the smoke coming off the flames engulfing, but not consuming, nearly every solid, liquid, or gas (and creature) on the plane. What one could see was usually distorted by heat ripples. Geographic features such as hills, mountains, and cliffs did not have a geologic lifespan because even the more solid areas slowly moved like a subterranean magma flow as seen on the Prime Material Plane.[33] While rare, the plane was home to a native plant known as the flame flower.[34]
If the Plane of Fire had weather, it was of course hot and deadly. Rains of hot ash moved about like thunderstorms, threatening those on or near the ground with hot embers and blinding ash.[3] Those in the air had to watch out for clouds of superheated steam blowing around and condensing scalding water on exposed surfaces. The water quickly evaporated and the cycle began anew.[35] Easier to avoid but just as deadly were the rivers of magma and "firefalls". Matter from other planes either evaporated, burned to ash, or melted into magma. Magma mixed with elemental fire formed a rapidly moving, incredibly hot slurry that coursed around the terrain and occasionally cascaded over a cliff edge to create a firefall, often manifesting an elemental vortex in the spectacular display.[3] The Great Wheel cosmology model explained these hazards and others as pockets of elements from all the other elemental, para-, and quasi-elemental planes that got sucked into the Plane of Fire and cast adrift to face their fate.[36] Cold spots could even be found where it felt like the middle of the Raurin desert at midday.[20] With a guide, a traveler could approach the borders with the other planes, where the smoke finally choked out the fire, or the fire became nothing but cold ash, or the molten earth absorbed the fire, or the radiance ultimately outshone the fire.[37]
The dangers of the plane could not be overstated, but those that survived the trip saw wonders and beauty at nearly every turn. Flame colors spanned the rainbow, from the vermilion of a forge hearth to the yellow-white of heated iron, from the blues and greens of alchemical reactions to the familiar candle-flame yellows and oranges. The conflagration formed fountains, jets, sheets, rivers, waves, walls, rains, cascades, clouds, swirls, and pits of brilliant incandescence on a scale found nowhere else.[14][38]
City of Brass[]
One famous refuge from the destructive heat was the City of Brass, home of the efreet. At the will of the grand sultan, the city was protected from the pervasive smoke and flames, and visitors enjoyed unrestricted vision and uncomfortable yet tolerable temperatures,[33][39] but walls and surfaces were still hot enough to burn unprotected flesh on contact.[1] The city sat in a bowl of golden brass 40 miles (64 kilometers) in diameter that floated about the plane[39] or hovered over a huge disk of obsidian that was cracked from the heat.[1][33] Architecture included soaring towers, grand minarets, and everything from tool sheds to palaces made of brass. The treasure vaults of the grand sultan, and his wrath towards any who attempted to acquire even a single piece, were legendary.[33][39]
The Elemental Foundation of Fire[]
An important feature of the plane was the Elemental Foundation of Fire, one of four magical switches (one in each of the four elemental planes) that when activated caused the Onyx Tower to rise on the Prime Material Plane.[40]
Inhabitants[]
Two efreet in the City of Brass.
Two efreet in the City of Brass.
Surprisingly many creatures and races could tolerate and even thrive in the Elemental Plane of Fire. First and foremost were the fire elementals, of course, being constructed directly from the substance of the plane itself. They could assume the form of animals or monsters from the Prime Material Plane,[14] mimic humanoid shape, or create composites with elemental shapes: a lava lion with a flaming mane and charcoal eyes, or a man-shaped torso with fire jets for arms and legs and a tiny tornado of flame for a head, for example. Fire elementals could usually be distinguished by the different colors of flame coming off their bodies, but when standing still they could blend into the background just as a rogue could hide in shadows.[2] Fire bats,[2][41] fire snakes,[42] harginn,[43] phantom stalkers,[2][44] and salamanders[2][45] were also thought to be natives of this plane, but the origins of magmen (or magmin)[3][32][46] and the azer[2][3][47][48] were hotly debated.
Visitors and immigrants to the Plane of Fire included brass[3][49] and gold dragons;[3][50] the efreet;[3][51] fire giants;[3][52] hell hounds;[3][53] some janni;[3][54] mephits of the fire, magma, and steam varieties;[3][55] pyrohydra;[3][56] rast;[3] and thoqqua.[3][57] Trading missions and diplomacy brought many peoples to the City of Brass, such as the mercane[3] and devils from the Nine Hells.[14]
The language spoken by the natives was called Ignan and it resembled the hisses and clicks of green wood being consumed by a burning campfire.[14]
History[]
On the material plane, in the land of Thay in Faerûn on Abeir-Toril in the Year of the Prince, 1357 DR, the tharchion Hargrid Tenslayer and the zulkirs Aznar Thrul and Nevron opened a gate to the Plane of Fire and contacted Fyzzar, a salamander lord, and Sultan Marrake of the efreet, bypassing Kossuth, the Lord of Flames. They offered an alliance, begging their help in conquering coastal cities in exchange for a permanent gate and a foothold in Faerûn in the conquered lands. The greedy elementals accepted, and launched an invasion, rapidly capturing or destroying the cities, before demanding their payment. The Thayans promptly betrayed them, expelling the efreet but missing the salamanders, who retaliated by launching the Salamander War. Aznar Thrul and the clerics of Kossuth persuaded the Firelord to intervene, and he sent his own fire elementals to drive the salamanders out.[58][59][60]
Realms[]
Kossuth, the Lord of Flames, the Firelord, Tyrant among Fire Elementals, the Fire Tyrant, once had a palace made of elemental fire here,[39][61][62][63][64] called the Crimson Pillar.[65] After the Spellplague, it was revealed that he was a primordial and he created a new realm in the Elemental Chaos called the Undying Pyre.[16]
Amaimon, King of the Azer, had no specific palace but traveled with his court from tower to tower (the azer were marvelous metalworkers and built grand towers scattered about the plane), holding feasts and fetes with dancing.[39][47]
Marrake al-Sidan al-Hariq ben Lazan, Sultan of the Efreet, the Lord of the Flame, the Potentate Incandescent, the Tempering and Eternal Flame of Truth, the Smoldering Dictator, and other titles, lived in his Charcoal Palace in the City of Brass.[19]
Imix, the Prince of Evil Fire Creatures, once dwelled within a tremendous active volcano on this plane in his pyramidic Temple of Ultimate Consumption.[39][66][67]
Zaaman Rul, the Prince of Good Fire Creatures, entrenched himself in his fortress called the Hidden Heart.[68]
Connections[]
There were several permanent connections to the Plane of Fire on Toril in the Material Plane.
In his lair beneath Zhentil Keep, the banelich Stallac Benadi constructed an open, one-way gate to the Elemental Plane of Fire. It was used for disposing of mining rubble and incinerating unused corpses.[69]
In the lair of the black dragon Harondalbar in the Sunset Mountains, Faren Starlight created a furnace that vented magically to the Plane of Fire. This was used for cooking, heating, and magical experimentation.[70]
Across the River Agis from the city of Memnon laid the ruins of the ancient efreet city of Memnonnar. Deep beneath these ruins was the Great Brass Gate, a vertical ring of solid brass about 100feet (30meters) in diameter, that formed a two-way portal to the Fire Plane. Seepage from this portal filled nearby passages with toxic smoke and heated the surrounding ruins to such a degree that some of the walls glowed.[71]
---
Elemental Plane of Water
The Elemental Plane of Water was an Inner Plane[9] or Elemental Plane[15] of the Great Wheel cosmology and the World Tree cosmology models. After the Spellplague, the Elemental Plane of Water collapsed into the Elemental Chaos, mixing with all the other Inner Planes.[16] Water is one of the four elements and two energies that make up the known universe and therefore of great interest to cosmologists.[17] This plane was abundant with life: native creatures born of the elemental nature of the plane itself, sentient water-breathing peoples, and most every species of aquatic life that could survive after being sucked through a vortex from their plane of origin.[14]
1 Cosmology
2 Description
3 Inhabitants
4 Realms
5 Appendix
5.1 Further Reading
5.2 References
5.3 Connections
Cosmology[]
meet a near an in the Plane of Water.
Adventurers meet a kuo-toan near an elemental vortex in the Plane of Water.
According to the Great Wheel cosmology model, the Elemental Plane of Water could be reached via the Ethereal Plane, an adjacent elemental plane, or by an elemental vortex.[9] The Pale River vortex on the Elemental Plane of Earth had its source here, and there was a vortex to the Plane of Air called the Bubble Net.[18] If traveling through the Deep Ethereal, a green curtain of vaporous color indicated the boundary of the Plane of Water's Border Ethereal region. Once in the Border Ethereal, a traveler could observe the Plane of Water and be detected by its denizens.[19] Using the spherical model, this plane was adjacent to the para-elemental planes of Ice and Ooze and the quasi-elemental planes of Steam and Salt.[10] Elemental vortices could occur wherever a high concentration or nearly pure form of an element was found, and could be temporary or permanent. Vortices to the Plane of Water could often be found in the deepest parts of the seas and oceans, in clear underground lakes, or as surface whirlpools in any large body of water.[20] Temporary gates could be created by the plane shift [21] spell or the abilities of high level druids.[9][22]
As described by the World Tree cosmology model, the Astral Plane connected all planes with the Prime Material Plane and the Ethereal Plane was only used for journeying between locations on the Prime.[23] The Elemental Plane of Water was not connected or coterminous with any other elemental plane,[24] however both the cross-planar rivers Styx and Oceanus were known to have vortices to the Plane of Water[14] and there were known portals to the Fated Depths.[25] The spell astral projection [26][27] could be used to reach the Plane of Water via a dark blue color pool.[28] Additionally, the gate [29][30] and plane shift [26][31] spells could be used to open a temporary portal to this plane, provided a lead fork tuned to the note of A was used as a spell focus.[32]
It was said that portals to the Water Plane could be found in the cold, clear depths of the Moonsea[33] in North Faerûn and the Riftlake[34] in the Great Rift of South Faerûn.
Description[]
There was no deep or shallow, no dark depths nor wavy surface, just an endless ocean that felt as if you were submerged several feet (say a couple meters) in any body of water on the Prime Material Plane. There was no sun, yet the water itself seemed to glow dimly with a bluish green luminescence. Volumes of water at any temperature and salinity could be found if you knew where to look or had a guide.[1][2][14] The Great Wheel cosmology model explained this by the proximity to the para- and quasi-elemental planes: water became cold and formed icebergs as you neared the Plane of Ice; water became brackish as you approached the Plane of Salt; water became silty and slimy as you neared the Plane of Ooze; water started to boil as you approached the Plane of Steam.[2] The World Tree cosmology described this plane as having all varieties of water constantly in motion, influenced by currents and tides. Life that depended on particular conditions flowed along with their preferred environment or suffered the consequences.[1][14] Impurities such as bubbles of air, chunks of earth, and even short-lived balls of fire could be found floating about due to elemental vortices or the workings of powerful beings. Habitats and settlements typically formed near sources of food and shelter, or near portals and vortices to facilitate trade.[35][36]
Supporting the teeming life of this plane were the corals and plants that made their way here and found purchase. Huge drifting three-dimensional reefs and loose spheres of freshwater grasses, kelp, and seaweed were home to myriad species and were fertile fishing spots.[37] Travelers had to keep in mind that large predators knew of these fishing grounds also, or else they might discover just how bite-sized they actually were. Just like a Prime ocean, the Elemental Plane of Water seemed to have no limit on how large some creatures could be as giant squid, aboleth, and kraken were known to prowl the plane.[14] Small creatures could be deadly too, with poisonous spines or barbed tails. The smallest of them all was perhaps the deadliest: algae that formed the infamous "red tide". Exposure of the eyes or lungs to the red tide caused blinding sickness.[37]
If the Plane of Water had any weather, it was the currents, whirlpools, tidal bores,[36] and flows of ice, steam, or silt[20] that could inconvenience a traveler or be a deadly surprise. Usually invisible, currents could be strong enough to pull visitors off in some direction for long distances before they were able to exit the current. Tidal bores were the most dangerous currents, hitting like a thrown boulder and carrying the unlucky creature away for miles (kilometers). Whirlpools were caused by countervailing currents that sucked everything in a tightening spiral, some of which led to vortices to other planes.[36] Ice and silt flows were fairly easy to spot before encountering, but steam flows were nearly undetectable and could cause nasty burns or boil the flesh from your bones.[20]
Inhabitants[]
It is difficult to determine what type of creatures were the most numerous in the Elemental Plane of Water but presumably the water elementals had the upper hand because they were manifestations of the plane itself.[14][35][38] They could take on any shape their fluid bodies could form but they were extremely hard to see, similar to the effect of a robe of blending,[35][39] and therefore were often described as blurry versions of Prime Material Plane animals and monsters typically of the aquatic variety.[14][40] Water weirds, an intelligent life form that could possess water elementals, were also thought to be native to this plane.[2][41]
All other peoples and creatures are interlopers or inadvertent immigrants by way of being sucked through an elemental vortex. Those that made a home here and thrived included some jann,[40][42][43] the marids,[40][42][44] the nereids,[40][45] and the tritons.[40][42][46] Besides almost every species of salt- and fresh-water marine life, there were reports of sightings or encounters with many creatures including aboleth,[42][47][48] charonadaemons,[40][49] black[42][50] and bronze dragons,[42][51] eyes of the deep,[40][52] giant nautiluses,[53] mephits of the ice, ooze, steam,[54] and water varieties,[42] mud-men,[40][55] sea hags,[42][56] tojanida,[42] varrdig,[57] and will-o'-wisps.[40][42][58]
Trade also brought many different races to the Elemental Plane of Water. Merchants that traded with the dao and the marids included aquatic elves, humans, kuo-toans, lizardfolk, locathah, mercanes, and sahuagin.[59]
Realms[]
Ahto, the Finnish King of the Seas, contemplated matters of water and current in is realm named Curling Wave.[60]
Ben-hadar, the Prince of Good Water Creatures, had a realm called the Coral Reef of Ssesurgass, containing a massive hidden fortress as his main seat.[61][62][63]
Blibdoolpoolp, the Sea Mother of the Kuo-toa, once dwelled in the Elemental Plane of Water in her domain called the Murky Depths.[2][64][65]
Eadro, deity of the locathah and merfolk, had his realm of Shelluria on the plane.[65]
Istishia, The Water Lord, God-King of Water Elementals, once had a realm called Sea of Timelessness here.[40][66][67][68][69][70][71] After the Spellplague he was revealed to be a primordial and he formed a domain in the Elemental Chaos called Cresting Spires.[16]
Kalbari al-Durrat al-Amwaj ibn Jari, Padishah of the Marids, Pearl of the Sea, Mother of Foam, Mistress of the Rivers, Savior of Fish, Patron of Waterspouts, and many more titles, had a palace near the Bubble Net called the Citadel of Ten Thousand Pearls.[18]
Olhydra, Princess of Evil Water Creatures,[72] once inhabited the ruins of a huge citadel made from black coral. She claimed it was the capital of a great empire that she destroyed long ago.[40]
Persana, god of the tritons, shared the realm of Shelluria with Eadro.[65]
Tefnut, mother of Geb in the Mulhorandi pantheon, was said to live here,[2][73] though she also had a realm in Bytopia.[74]
Appendix[]
Further Reading[]
Richard Baker, Joseph D. Carriker, Jr., Jennifer Clarke Wilkes (August 2005). Stormwrack. Edited by John D. Rateliff, John Thompson. (Wizards of the Coast), p. 9. ISBN 0-7869-3689-4.

File diff suppressed because it is too large Load Diff

View File

@ -54,7 +54,7 @@ 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
@ -63,6 +63,7 @@ from generate_npc import generate_npc as get_npc
# TypedDict
# ──────────────────────────────────────────────────────────────────────────────
class EncounterRequest(TypedDict, total=False):
"""
Параметры одного запроса случайной встречи.
@ -71,8 +72,10 @@ 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.
underwater, urban,
astral_sea, plane_of_air, plane_of_earth, plane_of_fire, plane_of_water,
feywild, shadowfell, lower_planes_le, lower_planes_ne, lower_planes_ce,
upper_planes_lg, upper_planes_ng, upper_planes_cg, planes_of_chaos, plane_of_law.
По умолчанию: forest.
level: Средний уровень группы (120).
@ -115,16 +118,16 @@ class EncounterRequest(TypedDict, total=False):
monster_los: Есть ли у монстров прямая видимость в начале?
По умолчанию: True.
"""
environment: str
level: int
encounter_type: Optional[str]
party_surprise_mods: Dict[str, int]
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]
reaction_mods: Dict[str, int]
party_foreknowledge: Optional[bool]
party_los: Optional[bool]
monster_foreknowledge: Optional[bool]
monster_los: Optional[bool]
monster_los: Optional[bool]
# ──────────────────────────────────────────────────────────────────────────────
@ -153,11 +156,11 @@ def _pick_encounter_type(env: str, requested_type: Optional[str] = None) -> str:
"""
# Список всех доступных типов из данных
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}
@ -315,7 +318,7 @@ def _determine_surprise(
"""Рассчитывает внезапность для одной стороны по правилам 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

View File

@ -19,23 +19,33 @@ from monsters_data import DATA
def _resolve_level_key(env: str, level: int) -> str:
"""
Определяет ключ диапазона уровней для данного биома.
Разные биомы имеют разные диапазоны в таблицах DMG.
"""
# Feywild и Shadowfell используют "11+" вместо "11-16" и "17-20"
if env in (
"feywild", "shadowfell",
"lower_planes_le", "lower_planes_ne", "lower_planes_ce",
"upper_planes_lg", "upper_planes_ng", "upper_planes_cg",
"planes_of_chaos", "plane_of_law",
):
if level <= 4: return "1-4"
if level <= 10: return "5-10"
return "11+"
if env == "grassland":
if level <= 5: return "1-5"
if level <= 10: return "6-10"
if level <= 16: return "11-16"
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"
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"
# Стихийные планы + все стандартные биомы
if level <= 4: return "1-4"
if level <= 10: return "5-10"
if level <= 16: return "11-16"
return "17-20"

View File

@ -9,274 +9,348 @@ get_npc_races.py — маппинг биома на типичные расы NP
NPC_RACES_BY_ENV: dict[str, list[str]] = {
"arctic": [
"Human", # основное население севера
"Goliath", # горные и арктические кочевники
"Half-Orc", # выносливые, часто в северных племенах
"Dwarf", # горные кланы у края ледников
"Orc", # северные племена
"Genasi", # воздушные и водные генази — редко, но возможно
"Tiefling", # изгнанники и одиночки
"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", # редко, но встречаются
"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", # оазисы, реки
"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", # природные генази
"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", # воздушные генази
"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", # редко
"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", # у горных троп
"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", # редко, миссионеры
"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", # охотники в темноте
"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", # миссионеры из морских храмов
"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", # наёмники на инфернальной службе
"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", # артисты, бродяги
],
"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", # редко, по древним договорам
"Human", # авантюристы, искатели
"Gith", # githyanki — хозяева Астрального моря
"Aasimar", # небесные странники
"Tiefling", # торговцы между планами
"Genasi", # воздушные генази
"Half-Elf", # дипломаты между планарными фракциями
"Dragonborn", # планарные рыцари
"Gnome", # исследователи и картографы планов
],
"plane_of_air": [
"Aarakocra", # коренные обитатели облачных городов
"Genasi", # воздушные генази, рождённые стихией
"Djinn", # правят дворцами, редко общаются с чужаками
"Elf", # искатели знаний, пилоты летучих кораблей
"Human", # купцы, наёмники, беглецы
"Tiefling", # дипломаты или изгнанники
"Aasimar", # посланники богов ветра
"Gith", # githyanki рейдеры, githzerai отшельники
"Gnome", # изобретатели летательных аппаратов
"Halfling", # торговцы на воздушных рынках
"Kenku", # шпионы и воры при дворах джиннов
"Tabaxi", # охотники на высоте, ценят свободу
"Dragonborn", # воины, связанные с небесными драконами
"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", # тёмные дварфы, злобные обитатели глубин
"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", # рейдеры или отшельники
"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", # рыбаки, певцы, хранители знаний
"Triton", # коренные обитатели, стражи глубин
"Genasi", # водные генази, лоцманы и целители
"Elf", # морские эльфы, кочевники рифов
"Human", # моряки, торговцы, пираты
"Aasimar", # посланники морских божеств
"Tiefling", # изгои или пираты глубин
"Dragonborn", # воины, связанные с морскими драконами
"Lizardfolk", # охотники на болотах и мелководье
"Dwarf", # подводные крепости, добытчики жемчуга
"Gnome", # изобретатели подводных аппаратов
"Marid", # джинны воды, своенравные правители
"Merfolk", # рыбаки, певцы, хранители знаний
],
"lower_planes_le": [
"Tiefling", # дети крови дьяволов - как дома
"Human", # авантюристы, культисты, проклятые
"Dragonborn", # инфернальные договоры
"Aasimar", # пленники или засланные агенты
"Half-Orc", # наёмники в дьявольских армиях
"Gith", # githyanki - союзники Тиамат и Nine Hells
"Genasi", # огненные генази
],
"lower_planes_ne": [
"Tiefling", # торговцы между планами зла
"Human", # души, потерявшие волю
"Gnome", # алхимики с тёмными сделками
"Half-Elf", # дипломаты и предатели
"Aasimar", # падшие или засланные
"Genasi", # тёмные генази
],
"lower_planes_ce": [
"Tiefling", # хаотики, обуреваемые демонической кровью
"Human", # безумцы и культисты Бездны
"Half-Orc", # воины, жаждущие резни
"Orc", # дети Груумша - дома в Бездне
"Aasimar", # пленники
"Dragonborn", # хаотические кланы хроматических драконов
],
"upper_planes_lg": [
"Aasimar", # стражи небес
"Human", # паладины, жрецы, праведники
"Dwarf", # горные кланы верных
"Elf", # эладрины горы Небес
"Half-Elf", # дипломаты добра
"Dragonborn", # рыцари металлических драконов
"Gith", # githzerai - медитирующие монахи
],
"upper_planes_ng": [
"Aasimar", # блуждающие добрые духи
"Human", # души добрых героев
"Elf", # лесные духи
"Gnome", # радостные исследователи
"Halfling", # блаженные странники
"Half-Elf", # дети двух миров
"Firbolg", # хранители природы
"Tabaxi", # свободные охотники
],
"upper_planes_cg": [
"Elf", # эладрины Арбореи - их родной план
"Aasimar", # героические духи
"Human", # герои Исгарда
"Half-Elf", # дети страсти двух миров
"Satyr", # фейские гуляки
"Gnome", # хаотически добрые изобретатели
"Tiefling", # искупившиеся
"Dragonborn", # доблестные воины
],
"planes_of_chaos": [
"Gith", # githzerai строят монастыри прямо в Лимбо
"Genasi", # воздушные и огненные - привычны к хаосу
"Tiefling", # прирождённые навигаторы хаоса
"Human", # безумцы и искатели
"Gnome", # гениальные безумцы
"Half-Elf", # адаптивные странники
"Aasimar", # хаотически добрые посланники
],
"plane_of_law": [
"Gith", # githyanki - торговцы и рейдеры Механуса
"Dwarf", # законники по природе
"Gnome", # инженеры и часовщики
"Human", # административные работники плана
"Half-Elf", # нейтральные дипломаты
"Aasimar", # законные добрые посланники
"Tiefling", # законные злые агенты дьяволов
"Hobgoblin", # военная машина - как дома
],
"feywild": [
"Elf", # тесно связаны с Феидом
"Half-Elf", # притянуты фейской кровью
"Gnome", # лесные гномы — почти фейские существа
"Satyr", # их родной план
"Tiefling", # изгнанники, ищущие красоты
"Halfling", # любопытные путешественники
"Human", # заблудившиеся или призванные
"Firbolg", # хранители фейских рощ
"Aasimar", # посланники в фейские дворы
"Genasi", # природные генази
"Tabaxi", # очарованные странники
"Dragonborn", # редко, по древним договорам
],
"shadowfell": [
"Human", # беглецы, авантюристы, проклятые
"Tiefling", # теневые торговцы
"Half-Elf", # искатели тайных знаний
"Aasimar", # падшие или миссионеры
"Elf", # тёмные эльфы, шары-шпионы
"Half-Orc", # изгои
"Gith", # githzerai-отшельники и githyanki-рейдеры
"Gnome", # алхимики теней
"Kenku", # вороны теней
],
}
@ -287,4 +361,4 @@ def get_npc_races(env: str) -> list[str]:
Если биом не найден или список пустой возвращает [],
что в get_npc означает 'все расы'.
"""
return NPC_RACES_BY_ENV.get(env.lower().strip(), [])
return NPC_RACES_BY_ENV.get(env.lower().strip(), [])

View File

@ -25,13 +25,25 @@ ENCOUNTER_TYPE_WEIGHTS: dict[str, dict[str, int]] = {
"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},
# Нижние планы
"lower_planes_le": {"PvP": 55, "PvE": 20, "PvX": 20, "PvSelf": 5}, # Nine Hells - строгая иерархия, много конфликтов
"lower_planes_ne": {"PvP": 45, "PvE": 25, "PvX": 25, "PvSelf": 5}, # Gray Waste - уныние, апатия, NE стихия
"lower_planes_ce": {"PvP": 60, "PvE": 15, "PvX": 20, "PvSelf": 5}, # Abyss - чистый хаос и резня
# Верхние планы
"upper_planes_lg": {"PvP": 25, "PvE": 25, "PvX": 30, "PvSelf": 20}, # Mount Celestia - порядок и добродетель
"upper_planes_ng": {"PvP": 20, "PvE": 30, "PvX": 30, "PvSelf": 20}, # Elysium/Beastlands - природа, дикость
"upper_planes_cg": {"PvP": 30, "PvE": 20, "PvX": 30, "PvSelf": 20}, # Arborea/Ysgard - героизм, страсть
# Планы закона и хаоса
"planes_of_chaos": {"PvP": 40, "PvE": 35, "PvX": 20, "PvSelf": 5}, # Limbo - всё нестабильно
"plane_of_law": {"PvP": 30, "PvE": 30, "PvX": 35, "PvSelf": 5}, # Mechanus - порядок, загадки механизмов
# Feywild и Shadowfell
"feywild": {"PvP": 35, "PvE": 25, "PvX": 30, "PvSelf": 10},
"shadowfell": {"PvP": 45, "PvE": 20, "PvX": 30, "PvSelf": 5},
}
# ──────────────────────────────────────────────────────────────────────────────

View File

@ -1,5 +1,7 @@
# Таблицы с монстрами и животными
from planar_monsters_data import PLANAR_DATA
DATA = {
"arctic": {
"1-4":
@ -1306,206 +1308,7 @@ DATA = {
(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"),
],
}
}
DATA.update(PLANAR_DATA)

File diff suppressed because it is too large Load Diff