sagaphone/spec/implementation-notes.md
Tim 06df9df339 Initial spec for Sagaphone
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>
2026-07-17 22:04:33 +02:00

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 PREFIXES and SUFFIXES lists per type for more variety.
  • person.home uses 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, TITLES lists 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:

  • weights list controls feature count distribution.
  • Add entries to HAIR_COLORS, EYE_COLORS, BUILDS, FEATURES to expand variety.
  • feature is None if no features rolled — LLM prompt should handle this gracefully.