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