sagaphone/spec/implementation-notes.md

323 lines
11 KiB
Markdown
Raw Normal View History

# 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.