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:
commit
06df9df339
7 changed files with 804 additions and 0 deletions
180
spec/data-model.md
Normal file
180
spec/data-model.md
Normal 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 | 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
|
||||
}
|
||||
```
|
||||
177
spec/event-system.md
Normal file
177
spec/event-system.md
Normal 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.
|
||||
322
spec/implementation-notes.md
Normal file
322
spec/implementation-notes.md
Normal 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
19
spec/line-logic.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue