From 06df9df339e74e3dab14b72420bb7a812b6c1c15 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 17 Jul 2026 22:04:33 +0200 Subject: [PATCH] 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 --- .gitignore | 29 ++++ README.md | 20 +++ SPEC.md | 57 +++++++ spec/data-model.md | 180 ++++++++++++++++++++ spec/event-system.md | 177 +++++++++++++++++++ spec/implementation-notes.md | 322 +++++++++++++++++++++++++++++++++++ spec/line-logic.md | 19 +++ 7 files changed, 804 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 spec/data-model.md create mode 100644 spec/event-system.md create mode 100644 spec/implementation-notes.md create mode 100644 spec/line-logic.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a3a2bc --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e50a2f --- /dev/null +++ b/README.md @@ -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` diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..0f25d88 --- /dev/null +++ b/SPEC.md @@ -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. diff --git a/spec/data-model.md b/spec/data-model.md new file mode 100644 index 0000000..717ba08 --- /dev/null +++ b/spec/data-model.md @@ -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 | 0–12 | +| Teen | 12–16 | +| Young Adult | 16–32 | +| Adult | 32–50 | +| Senior | 50–X (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 +} +``` diff --git a/spec/event-system.md b/spec/event-system.md new file mode 100644 index 0000000..541bfa6 --- /dev/null +++ b/spec/event-system.md @@ -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. diff --git a/spec/implementation-notes.md b/spec/implementation-notes.md new file mode 100644 index 0000000..805fe70 --- /dev/null +++ b/spec/implementation-notes.md @@ -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. diff --git a/spec/line-logic.md b/spec/line-logic.md new file mode 100644 index 0000000..5afe950 --- /dev/null +++ b/spec/line-logic.md @@ -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.