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>
322 lines
11 KiB
Markdown
322 lines
11 KiB
Markdown
# 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.
|