# 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}." )