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>
This commit is contained in:
Tim Otlik 2026-07-17 22:04:33 +02:00
commit 06df9df339
7 changed files with 804 additions and 0 deletions

29
.gitignore vendored Normal file
View file

@ -0,0 +1,29 @@
# Lock files
uv.lock
bun.lockb
# Secrets & live config (commit *.toml.example only)
secrets.toml
*.toml
!*.toml.example
!pyproject.toml
# Python
__pycache__/
*.pyc
.venv/
.pytest_cache/
.ruff_cache/
# Node / Svelte
node_modules/
.svelte-kit/
build/
dist/
# Pulumi
Pulumi.*.yaml
!Pulumi.yaml
# OS
.DS_Store

20
README.md Normal file
View file

@ -0,0 +1,20 @@
# Sagaphone
A web-based, procedural family-saga narrator with TTS — generates random
family trees and narrates their story out loud. Intended as a "fall asleep"
tool: set a timer, and it reads family sagas until time runs out.
See [SPEC.md](SPEC.md) for the full spec, architecture, and design areas.
## Status
Spec-only. Implementation not started yet.
## Stack (planned)
- Frontend: SvelteKit
- Backend: FastAPI (Python, `uv`)
- TTS: Piper (server-side)
- Narration: Anthropic Haiku API (server-side)
- Reverse proxy: Caddy
- Deployment target: Zeus, `sagaphone.orbitstack.casa`

57
SPEC.md Normal file
View file

@ -0,0 +1,57 @@
# Sagaphone — Spec
A web-based, procedural family-saga narrator with TTS. Sagaphone generates a
random family tree and narrates its story via text-to-speech in the browser.
It's meant as a "fall asleep" tool: open it on an iPad or phone, set a timer,
and it reads family sagas until the timer runs out.
**Language of narration:** English. **Tone:** fantasy-medieval.
## Architecture
- **Frontend:** SvelteKit — audio playback + UI only, no business logic.
- **Backend:** FastAPI, deployed on Zeus, reachable via a URL (e.g.
`sagaphone.orbitstack.casa`).
- **Reverse proxy:** Caddy.
- **TTS:** Piper, runs server-side, audio streamed to the browser. Deliberately
not Web Speech API — avoids iOS Audio Session issues, works in all browsers.
- **Narration:** Anthropic Haiku API, called server-side only (~$1.50/month at
daily use). API key is never exposed to the client — no API keys in the
frontend at all.
## Flow
1. User opens Sagaphone in the browser and sets a timer.
2. On start: a family tree up to generation 6 is pre-generated as JSON — pure
random logic, no LLM involved, runs in milliseconds.
3. An active line (branch) is picked at random.
4. The LLM generates a paragraph for the active person's current life stage.
5. TTS reads the paragraph aloud; the next paragraph is fetched in the
background while it plays.
6. When a person's story is fully told: move to the next person in the active
line, or switch lines if the line has died out.
7. Repeat until the timer runs out.
## Features / Design Areas
- [Data Model](spec/data-model.md) — enums, `Person`, `Stage`, `EventEntry`, `FamilyTree`, `World`.
- [Event System](spec/event-system.md) — event categories, death probability, event tables, follow-bonus map.
- [Line Logic](spec/line-logic.md) — how the active family branch is chosen and switched.
- [Implementation Notes](spec/implementation-notes.md) — event weights/stage locks, living-family lookup, location/name/appearance generators.
## Open Questions (from planning, still unresolved)
- [ ] Tune `magic_factor` and death probabilities via playtesting.
- [ ] Fill event tables with more concrete fantasy events.
- [ ] Define appearance generation in more detail.
- [ ] Work out the LLM prompt structure per life stage.
- [ ] Test the audio-loop workaround on iOS.
- [ ] Decide name / seed mechanism for the first test person.
- [ ] Plan deployment on Zeus.
## Source
Planning originally done in Notion ("Sagaphone" page under "Dokumentation").
This repo is the implementation target; specs here are the source of truth
going forward — update them alongside the code, don't let Notion and the repo
drift apart.

180
spec/data-model.md Normal file
View file

@ -0,0 +1,180 @@
# Data Model
All identifiers and code are in English.
## Enums
```python
class Gender(IntEnum):
MALE = 0
FEMALE = 1
NONBINARY = 2
class PartnerType(IntEnum):
MARRIAGE = 0
AFFAIR = 1
POLITICAL = 2
ILLEGITIMATE = 3
class StageType(IntEnum):
CHILD = 0
TEEN = 1
YOUNG_ADULT = 2
ADULT = 3
SENIOR = 4
class Event(IntEnum):
# Category 1 — Death
DEATH_ILLNESS = 10
DEATH_ACCIDENT = 11
DEATH_COMBAT = 12
DEATH_OLD_AGE = 13
DEATH_CHILDBIRTH = 14 # mother dies during birth
# Category 2 — Partnership
PARTNER_MARRIAGE = 20
PARTNER_AFFAIR = 21
PARTNER_ENGAGEMENT = 22
PARTNER_POLITICAL = 23
PARTNER_CHILDHOOD_PROMISE = 24
# Category 3 — Offspring
CHILD_BORN = 30
CHILD_TWINS = 31
CHILD_TRIPLETS = 32
CHILD_STILLBORN = 33
CHILD_ADOPT = 34 # only path to offspring for NONBINARY; from YOUNG_ADULT onwards
# Category 4 — Daily Life
DAILY_FRIENDSHIP = 40
DAILY_FAMILY = 41
DAILY_TEMP_CHAR = 42
# Category 5 — Travel
TRAVEL_NEAR = 50
TRAVEL_FAR = 51
TRAVEL_PILGRIMAGE = 52
# Category 6 — Learning
LEARN_APPRENTICESHIP = 60
LEARN_MENTOR = 61
LEARN_SELF = 62
# Category 7 — Conflict (personal)
CONFLICT_PERSONAL = 70
CONFLICT_FIGHT = 71
CONFLICT_FEUD = 72
# Category 8 — Discovery
DISCOVER_OBJECT = 80
DISCOVER_SPELL = 81
DISCOVER_WONDER = 82
DISCOVER_KNOWLEDGE = 83
# Category 9 — Local Conflict
LOCAL_REBELLION = 90
LOCAL_PLAGUE = 91
LOCAL_DISASTER = 92
LOCAL_CRISIS = 93
# Category 10 — Intrigue
INTRIGUE_POLITICAL = 100
INTRIGUE_EXILE = 101
INTRIGUE_CONSPIRACY = 102
# Category 11 — Illness / Injury
ILLNESS_WEAK = 110
ILLNESS_SEVERE = 111
ILLNESS_CHRONIC = 112
INJURY_ACCIDENT = 113
INJURY_COMBAT = 114
```
`event_type // 10` → category, `event_type % 10` → subtype within category.
## Person
```python
Person {
id: str
name: str
gender: Gender
birth_year: int # absolute, set when person is created via parent event
death_year: int | None # birth_year + age_at_death
alive: bool
appearance: dict
profession: str | None # set by a LEARN event
home: str
is_ancestor: bool # True only for Person 1
parent_id: str | None # previous active node; None = joined from outside
generation: int
partners: PartnerEntry[]
events: str[] # event IDs in chronological order
acquaintances: str[] # IDs from person_pool only (not family tree)
stages: Stage[]
}
PartnerEntry {
person_id: str
type: PartnerType
children_ids: str[] # all children from this union
}
```
## Stage
```python
Stage {
type: StageType
age: { from: int, to: int } # relative to person
events: str[] # event IDs
}
```
### Stage Age Ranges
| Stage | Age |
|-------------|----------------|
| Child | 012 |
| Teen | 1216 |
| Young Adult | 1632 |
| Adult | 3250 |
| Senior | 50X (LLM decides) |
## EventEntry
```python
EventEntry {
id: str
type: Event # e.g. Event.ILLNESS_SEVERE
age: int # age of person at time of event
# absolute_year: @property -> person.birth_year + self.age
location: str | None # None = takes place at person.home
location_is_temp: bool # True = travel destination, False = known location
participants: str[] # person_ids or pool_ids
result: dict # predetermined simulation outcome
context: dict # extra info passed to LLM prompt
follow_bonus: Event[] # event types made more likely after this event
# e.g. after TRAVEL_FAR -> [DISCOVER_OBJECT, DISCOVER_WONDER]
}
```
## FamilyTree & person_pool
```python
FamilyTree {
persons: dict[str, Person] # all family tree persons
person_pool: dict[str, Person] # temporary chars with no tree membership
# e.g. random villager
# distant cousin -> lives in persons, not here
world: World
current_generation: int
active_node: str # person_id of currently narrated person
}
World {
start_year: int # randomly generated, max 4 digits, no calendar system stated
name: str
epoch_flavor: str # setting keywords passed to LLM
}
```

177
spec/event-system.md Normal file
View file

@ -0,0 +1,177 @@
# Event System
See [Data Model](data-model.md) for the `Event`, `EventEntry`, `Stage` types
this section builds on.
## Death Probability per Stage
Before every event, a roll decides whether the person dies. A minimum number
of events per stage must happen before death is even possible. Past that
minimum, the probability rises with a `magic_factor`.
```
death_probability = death_at_stage[stage]
Generate events until the person dies.
minimum_events = events_per_stage[stage]
if current_event_count > events_per_stage[stage]:
death_probability += (current_event_count - events_per_stage[stage]) * magic_factor
```
**Starting values (to be tuned via playtesting):**
| Stage | Min. Events | Base Death % |
|-------------|-------------|--------------|
| Child | 2 | 30% |
| Teen | 3 | 10% |
| Young Adult | 3 | 25% |
| Adult | 3 | 35% |
| Senior | 3 | 60% |
Person 1 (the ancestor / `is_ancestor = True`) is guaranteed to survive every
stage — no early death for them.
## Event Categories
1. **Death** — events leading to death (only rolled if death was decided).
2. **Partnership** — love, marriage, engagement, political engagement,
childhood promise.
3. **Offspring** — birth, complications (mother dies giving birth),
twins/triplets.
4. **Daily Life** — daily routine, family interactions, temporary characters.
5. **Travel** — the person travels, experiences things, temp characters
possible.
6. **Learning** — formative learning, training, profession.
7. **Conflict/Fight** — not fatal for the active person.
8. **Discovery** — object, spell, natural wonder etc. (boosted probability
after travel).
9. **Local Conflict** — rebellion, crisis, natural disaster, and how the
person handles it.
10. **Intrigue** — political intrigue, social conflict.
11. **Illness/Injury** — not fatal, but formative.
## Event Tables
Concrete fantasy events per category, used during simulation (Phase 1). Each
entry represents a possible `result`/`context` payload for a given `Event`
type. The LLM receives these as narrative input during Phase 2.
### Category 1 — Death
| Event Type | Description | Notes |
|---|---|---|
| DEATH_ILLNESS | Succumbs to a long illness | Can follow ILLNESS_CHRONIC |
| DEATH_ACCIDENT | Fatal accident (e.g. fall, fire, drowning) | Higher chance in CHILD stage |
| DEATH_COMBAT | Killed in a fight or battle | Can follow CONFLICT_FIGHT or LOCAL_REBELLION |
| DEATH_OLD_AGE | Dies peacefully of old age | Senior stage only |
| DEATH_CHILDBIRTH | Mother dies giving birth | Triggers active node switch to newborn if mother was the active node |
### Category 2 — Partnership
| Event Type | Description | Notes |
|---|---|---|
| PARTNER_MARRIAGE | Formal marriage ceremony | From TEEN stage onwards |
| PARTNER_AFFAIR | Secret or open love affair | May produce illegitimate children |
| PARTNER_ENGAGEMENT | Formal betrothal, not yet married | Can be broken off by later event |
| PARTNER_POLITICAL | Arranged marriage for political gain | Often initiated by parent |
| PARTNER_CHILDHOOD_PROMISE | Two children swear to marry one day | Can be fulfilled or broken later |
### Category 3 — Offspring
| Event Type | Description | Notes |
|---|---|---|
| CHILD_BORN | A healthy child is born | Sets child's birth_year |
| CHILD_TWINS | Twin birth | Two new Person objects created |
| CHILD_TRIPLETS | Triplet birth | Three new Person objects created |
| CHILD_STILLBORN | Child is born dead | No new Person object; affects parents narratively |
| CHILD_ADOPT | Person adopts a child | Only path to offspring for NONBINARY; possible for all genders at reduced weight. From YOUNG_ADULT onwards. |
### Category 4 — Daily Life
| Event Type | Description | Notes |
|---|---|---|
| DAILY_FRIENDSHIP | Forms a meaningful friendship | New person added to acquaintances |
| DAILY_FAMILY | Notable interaction with a family member | Uses existing person from tree |
| DAILY_TEMP_CHAR | Encounter with a temporary character | New person added to person_pool |
### Category 5 — Travel
| Event Type | Description | Notes |
|---|---|---|
| TRAVEL_NEAR | Journey to a nearby village or town | location_is_temp = True |
| TRAVEL_FAR | Long journey to a distant land | Increases follow_bonus for DISCOVER_* |
| TRAVEL_PILGRIMAGE | Religious or spiritual journey | May trigger LEARN_SELF or DISCOVER_WONDER |
### Category 6 — Learning
| Event Type | Description | Notes |
|---|---|---|
| LEARN_APPRENTICESHIP | Begins formal training under a master | Sets profession |
| LEARN_MENTOR | Gains wisdom from an older figure | May use existing acquaintance as mentor |
| LEARN_SELF | Self-taught skill or insight | Often follows TRAVEL or DISCOVER |
### Category 7 — Personal Conflict
| Event Type | Description | Notes |
|---|---|---|
| CONFLICT_PERSONAL | Serious argument or falling out | Can involve family or acquaintance |
| CONFLICT_FIGHT | Physical altercation, non-fatal | May leave injury (INJURY_COMBAT follow) |
| CONFLICT_FEUD | Long-running grudge with a person or family | Can span multiple stages |
### Category 8 — Discovery
| Event Type | Description | Notes |
|---|---|---|
| DISCOVER_OBJECT | Finds a curious or valuable object | Object stored in result.item |
| DISCOVER_SPELL | Stumbles upon a magical formula or ritual | Higher chance if LEARN_SELF preceded |
| DISCOVER_WONDER | Witnesses a natural or supernatural wonder | Often follows TRAVEL_FAR |
| DISCOVER_KNOWLEDGE | Uncovers a secret, history, or forbidden lore | May trigger INTRIGUE follow events |
### Category 9 — Local Conflict
| Event Type | Description | Notes |
|---|---|---|
| LOCAL_REBELLION | A local uprising disrupts daily life | Person may flee, fight, or hide |
| LOCAL_PLAGUE | Disease sweeps through the region | Increases death probability for all |
| LOCAL_DISASTER | Natural disaster (flood, fire, earthquake) | May destroy home or kill acquaintances |
| LOCAL_CRISIS | Economic or political crisis in the region | Affects profession and home stability |
### Category 10 — Intrigue
| Event Type | Description | Notes |
|---|---|---|
| INTRIGUE_POLITICAL | Person becomes entangled in a power struggle | Higher chance for persons with political partners |
| INTRIGUE_EXILE | Person is banished from home or community | Changes person.home |
| INTRIGUE_CONSPIRACY | Person is targeted by a secret group | May follow DISCOVER_KNOWLEDGE |
### Category 11 — Illness & Injury
| Event Type | Description | Notes |
|---|---|---|
| ILLNESS_WEAK | Mild illness, recovers quickly | Minimal long-term effect |
| ILLNESS_SEVERE | Serious illness, leaves a mark | May set a permanent trait in appearance |
| ILLNESS_CHRONIC | Ongoing condition affecting daily life | Stored as permanent result flag |
| INJURY_ACCIDENT | Hurt in an accident | Higher chance after TRAVEL or LOCAL_DISASTER |
| INJURY_COMBAT | Wounded in a fight | Follows CONFLICT_FIGHT or LOCAL_REBELLION |
## Follow Bonus Map
Some events make certain follow-up events more likely, stored in
`EventEntry.follow_bonus`.
| Trigger Event | follow_bonus |
|---|---|
| TRAVEL_FAR | DISCOVER_OBJECT, DISCOVER_WONDER |
| TRAVEL_PILGRIMAGE | DISCOVER_WONDER, LEARN_SELF |
| CONFLICT_FIGHT | INJURY_COMBAT |
| LOCAL_REBELLION | DEATH_COMBAT, INJURY_COMBAT, INTRIGUE_EXILE |
| LOCAL_PLAGUE | DEATH_ILLNESS, ILLNESS_SEVERE |
| LOCAL_DISASTER | INJURY_ACCIDENT, INTRIGUE_EXILE |
| DISCOVER_KNOWLEDGE | INTRIGUE_CONSPIRACY, INTRIGUE_POLITICAL |
| PARTNER_POLITICAL | INTRIGUE_POLITICAL |
| LEARN_APPRENTICESHIP | LEARN_MENTOR |
| ILLNESS_CHRONIC, ILLNESS_SEVERE | DEATH_ILLNESS |
| INJURY_COMBAT | ILLNESS_SEVERE, DEATH_COMBAT |
See [Implementation Notes](implementation-notes.md) for the concrete weight
calculation (`get_effective_chance`) and stage-lock rules that gate which
events are even eligible.

View file

@ -0,0 +1,322 @@
# 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.
```python
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
```python
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.
```python
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).
```python
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.
```python
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.
```python
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`.
```python
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.

19
spec/line-logic.md Normal file
View file

@ -0,0 +1,19 @@
# Line Logic
- A **family line** is a branch: one person and all of their descendants.
- All descendants count — legitimate, illegitimate, children from multiple
partnerships.
- The active line stays on the current branch until it dies out.
- "Dies out" means nobody in that branch has living descendants (checked up
to `current_generation - 1`).
- When a branch dies out → a random pick among the other existing lines.
- Background persons (not currently narrated) are still simulated — events,
death, partnerships, children — just not narrated.
## Edge Cases
- At least one line must always remain.
- If the last line dies out prematurely → search earlier generations for
branches that haven't been narrated yet.
- From generation 6 onwards, no new generations are simulated in the
background.