Pulled from Notion project docs: overview, architecture, data model, event system, line logic, and implementation notes for the family-tree generator + TTS narrator. Co-Authored-By: Claude <noreply@anthropic.com>
11 KiB
Implementation Notes
Snippets and implementation details for logic that may need tuning or adjustment later. Not the full codebase — only isolated pieces worth documenting separately. Values marked "tunable" are starting points, expected to change during playtesting.
Event Weights & Stage Filters
Hard Locks — events impossible at certain stages or genders
Checked before any weight calculation. Events not listed here are always allowed regardless of stage.
STAGE_LOCKS: dict[Event, callable] = {
# Offspring — not for CHILD/TEEN, not for SENIOR
Event.CHILD_BORN: lambda s, p: s >= StageType.YOUNG_ADULT and s < StageType.SENIOR and p.gender != Gender.NONBINARY,
Event.CHILD_TWINS: lambda s, p: s >= StageType.YOUNG_ADULT and s < StageType.SENIOR and p.gender != Gender.NONBINARY,
Event.CHILD_TRIPLETS: lambda s, p: s >= StageType.YOUNG_ADULT and s < StageType.SENIOR and p.gender != Gender.NONBINARY,
Event.CHILD_ADOPT: lambda s, p: s >= StageType.YOUNG_ADULT,
Event.DEATH_CHILDBIRTH: lambda s, p: s >= StageType.YOUNG_ADULT and p.gender == Gender.FEMALE,
# Death
Event.DEATH_COMBAT: lambda s, p: s >= StageType.TEEN,
Event.DEATH_OLD_AGE: lambda s, p: s == StageType.SENIOR,
# Partnership
Event.PARTNER_CHILDHOOD_PROMISE:lambda s, p: s <= StageType.TEEN,
Event.PARTNER_MARRIAGE: lambda s, p: s >= StageType.TEEN,
Event.PARTNER_AFFAIR: lambda s, p: s >= StageType.TEEN,
Event.PARTNER_ENGAGEMENT: lambda s, p: s >= StageType.TEEN,
Event.PARTNER_POLITICAL: lambda s, p: s >= StageType.TEEN,
# Travel
Event.TRAVEL_FAR: lambda s, p: s >= StageType.TEEN,
Event.TRAVEL_PILGRIMAGE: lambda s, p: s >= StageType.YOUNG_ADULT,
# Learning
Event.LEARN_APPRENTICESHIP: lambda s, p: s >= StageType.TEEN,
Event.LEARN_MENTOR: lambda s, p: s <= StageType.ADULT,
# Conflict
Event.CONFLICT_FEUD: lambda s, p: s >= StageType.YOUNG_ADULT,
# Daily family — also requires living family members (see get_living_family)
Event.DAILY_FAMILY: lambda s, p: True, # stage always ok, checked separately
}
Same-sex Marriage Weight Reduction
SAME_SEX_MARRIAGE_MULTIPLIER = 0.3 # tunable
def is_same_sex(person: Person, partner: Person) -> bool:
# NONBINARY never counts as same-sex
if person.gender == Gender.NONBINARY or partner.gender == Gender.NONBINARY:
return False
return person.gender == partner.gender
Follow Bonus Mechanism
Follow bonuses are additive on top of the base event chance.
def get_effective_chance(
event_type: Event,
base_chance: float,
active_follow_bonuses: dict[Event, dict[Event, float]]
) -> float:
"""
active_follow_bonuses: { trigger_event: { bonus_event: bonus_value } }
Example: { Event.INJURY_COMBAT: { Event.ILLNESS_SEVERE: 0.10 } }
"""
bonus = sum(
bonuses[event_type]
for bonuses in active_follow_bonuses.values()
if event_type in bonuses
)
return base_chance + bonus
Living Family Lookup
Used to check whether DAILY_FAMILY is valid, and to provide participant
candidates for that event. Family = parents, siblings, own children,
aunts/uncles (parent's siblings), cousins (children of aunts/uncles).
def get_living_family(person: Person, tree: FamilyTree, current_year: int) -> list[str]:
candidates = set()
# 1. Parents
if person.parent_id:
parent = tree.persons.get(person.parent_id)
if parent:
candidates.add(parent.id)
# other parent = partner of parent from whom person descends
for pe in parent.partners:
if person.id in pe.children_ids:
candidates.add(pe.person_id)
# 2. Siblings = other children of same parents
for pid in candidates.copy():
p = tree.persons.get(pid)
if p:
for pe in p.partners:
candidates.update(pe.children_ids)
candidates.discard(person.id)
# 3. Own children
for pe in person.partners:
candidates.update(pe.children_ids)
# 4. Aunts/Uncles (parent's siblings) + Cousins (their children)
grandparent_ids = set()
if person.parent_id:
parent = tree.persons.get(person.parent_id)
if parent and parent.parent_id:
grandparent = tree.persons.get(parent.parent_id)
if grandparent:
grandparent_ids.add(grandparent.id)
for pe in grandparent.partners:
if parent.id in pe.children_ids:
grandparent_ids.add(pe.person_id)
for gid in grandparent_ids:
grandparent = tree.persons.get(gid)
if grandparent:
for pe in grandparent.partners:
for child_id in pe.children_ids:
if child_id != person.parent_id:
candidates.add(child_id) # aunt/uncle
aunt_uncle = tree.persons.get(child_id)
if aunt_uncle:
for ape in aunt_uncle.partners:
candidates.update(ape.children_ids) # cousins
# filter: alive and already born
return [
pid for pid in candidates
if pid in tree.persons
and tree.persons[pid].alive
and tree.persons[pid].birth_year <= current_year
]
Location Generator
Two functions: generate_location(type) when a specific type is needed,
generate_random_location() when any type is fine. Returns a dict with
name and type — used for person.home and event locations.
from enum import IntEnum
import random
class LocationType(IntEnum):
VILLAGE = 0
TOWN = 1
CITY = 2
RIVER = 3
LAKE = 4
MOUNTAIN = 5
FOREST = 6
LANDMARK = 7
PREFIXES = {
LocationType.VILLAGE: ["Stock", "Birch", "Ash", "Elm", "Green", "Black", "Cold", "Old"],
LocationType.TOWN: ["New", "Old", "Chester", "Alden", "Iron", "Stone", "Crow"],
LocationType.CITY: ["Lim", "Dur", "Solm", "Alten", "Harken", "Veld", "Orm"],
LocationType.RIVER: ["Willow", "Silver", "Black", "Swift", "Cold", "Amber", "Dark"],
LocationType.LAKE: ["Hark", "Mirror", "Grey", "Still", "Deep", "Dusk"],
LocationType.MOUNTAIN: ["Feld", "Grey", "Iron", "Storm", "Frost", "Ash", "Grim"],
LocationType.FOREST: ["Dark", "Elder", "Moss", "Thorn", "Whisper", "Hollow"],
LocationType.LANDMARK: ["Grand", "Ancient", "Broken", "Lost", "Black", "Hollow"],
}
SUFFIXES = {
LocationType.VILLAGE: ["heim", "dorf", "wick", "ford", "ton", "stead"],
LocationType.TOWN: ["shire", "ham", "burg", "haven", "gate", "cross"],
LocationType.CITY: ["burg", "mark", "hold", "spire", "gate", "wall"],
LocationType.RIVER: ["creek", "brook", "run", "water", "stream", "beck"],
LocationType.LAKE: ["lake", "mere", "pool", "water", "tarn"],
LocationType.MOUNTAIN: ["fell", "peak", "stone", "berg", "crag", "tor"],
LocationType.FOREST: ["wood", "forest", "grove", "thicket", "weald"],
LocationType.LANDMARK: ["stone", "rock", "spire", "arch", "ruin", "mound"],
}
def generate_location(location_type: LocationType) -> dict:
prefix = random.choice(PREFIXES[location_type])
suffix = random.choice(SUFFIXES[location_type])
return {
"name": f"{prefix}{suffix}",
"type": location_type
}
def generate_random_location() -> dict:
location_type = random.choice(list(LocationType))
return generate_location(location_type)
# Examples:
# generate_location(LocationType.VILLAGE) -> {"name": "Ashwick", "type": LocationType.VILLAGE}
# generate_location(LocationType.RIVER) -> {"name": "Willowcreek","type": LocationType.RIVER}
# generate_location(LocationType.MOUNTAIN) -> {"name": "Frostcrag", "type": LocationType.MOUNTAIN}
Tuning knobs:
- Expand
PREFIXESandSUFFIXESlists per type for more variety. person.homeuses this dict directly:{"name": "Ashwick", "type": LocationType.VILLAGE}.
Name Generator
Called once when a Person object is created. Returns a full name string. No
gender filtering needed — all titles use "the" and are gender-neutral in
English.
import random
FIRST_NAMES = [
"Julius", "Olaf", "Maria", "Edric", "Mira", "Bram", "Signe", "Aldric",
"Freya", "Cassius", "Isolde", "Roran", "Thyra", "Leif", "Seren",
"Eadric", "Wulfric", "Astrid", "Bjorn", "Ingrid", "Ragnar", "Elara",
"Cedric", "Maren", "Aldis", "Torben", "Sigrid", "Halvard", "Liora"
]
LAST_NAMES = [
"Voss", "Eisfeld", "Brunnwald", "Alliatus", "Andrine", "Kaltmar",
"Steinholz", "Ashvale", "Dornwald", "Frey", "Ironwood", "Blackthorn",
"Greymoor", "Coldwater", "Ashford", "Dunmore", "Ravenscar"
]
TITLES = [
"the Butcher", "the Greedy", "the Bold", "the Wise", "the Unyielding",
"the Gentle", "the Wanderer", "the Red", "the Pale", "the Scarred",
"the Old", "the Young", "the Swift", "the Lame", "the Blind",
"the Cruel", "the Just", "the Meek", "the Loud", "the Silent"
]
LAST_NAME_CHANCE = 0.75 # tunable
TITLE_CHANCE = 0.20 # tunable
def generate_name() -> str:
first = random.choice(FIRST_NAMES)
last = random.choice(LAST_NAMES) if random.random() < LAST_NAME_CHANCE else None
title = random.choice(TITLES) if random.random() < TITLE_CHANCE else None
parts = [first]
if last: parts.append(last)
if title: parts.append(title)
return " ".join(parts)
# Possible outputs:
# "Julius"
# "Julius Voss"
# "Julius Voss the Butcher"
# "Olaf the Bold"
# "Maria Andrine the Greedy"
Tuning knobs:
LAST_NAME_CHANCE— probability of having a last name.TITLE_CHANCE— probability of having a title.- Expand
FIRST_NAMES,LAST_NAMES,TITLESlists freely.
Appearance Generator
Called once when a Person object is created. Returns a dict stored in
person.appearance.
import random
HAIR_COLORS = [
"black", "dark brown", "brown", "auburn",
"blonde", "grey", "white", "red"
]
EYE_COLORS = [
"brown", "grey", "green", "blue", "hazel", "amber"
]
BUILDS = [
"lean", "wiry", "stocky", "broad-shouldered",
"slender", "heavyset", "average"
]
FEATURES = [
"scar on left cheek",
"crooked nose",
"missing finger",
"birthmark on neck",
"unusually pale skin",
"deep-set eyes",
"prominent jaw",
"freckles",
"calloused hands",
"walks with a slight limp",
"unusually tall",
"unusually short",
]
def generate_appearance() -> dict:
# weights: higher chance for fewer features
# [0, 1, 2, 3, 4] -> [30%, 35%, 20%, 10%, 5%]
k = random.choices([0, 1, 2, 3, 4], weights=[30, 35, 20, 10, 5])[0]
features = random.sample(FEATURES, k=k)
return {
"hair": random.choice(HAIR_COLORS),
"eyes": random.choice(EYE_COLORS),
"build": random.choice(BUILDS),
"feature": ", ".join(features) if features else None
}
Tuning knobs:
weightslist controls feature count distribution.- Add entries to
HAIR_COLORS,EYE_COLORS,BUILDS,FEATURESto expand variety. featureisNoneif no features rolled — LLM prompt should handle this gracefully.