A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Forge per-campaign cultures and use them to name everything

Even after the anti-rut arc work, every campaign was still drifting
toward the same handful of fantasy names — Aldric Voss, Mira, Vera
Blackwater, Tilde Barren — because Claude has hot-path defaults for
naming that don't really respond to context. This adds a phonotactic
name generator and wires the seeder, planner, and DM to use it for
every named entity instead of typing names directly.

The new module (src/storied/names/) is self-contained and designed
to be lifted out into a standalone package later — there's an
AGENTS.md plus an AST-scan test that fails the build if anything in
storied.names imports from elsewhere in storied. README.md inside
the module covers attribution and design history; the architecture
owes a substantial debt to William Annis's Lexifer, the conlang
community's (C)V(C) template tradition, and standard phonological
machinery (sonority sequencing, Zipfian/Gusein-Zade frequency).

Cultures are forged per-campaign, not pre-built. The forge picks a
random hand-encoded inventory inspired by a real language family
(Welsh, Old Norse, Quechuan, etc. — about 25 of them), trims and
re-weights it, picks syllable templates and morphology pools, then
runs the assembled engine to generate the culture's *own* self-name
as its first emission. That self-name becomes the file id and the
canonical reference. Each campaign gets its own 4-8 cultures during
seeding and they never collide across campaigns.

Cultures are first-class world entities — they live alongside npcs
and locations, get indexed into the search.db with content_type=
cultures, and are discoverable via the existing recall tool. The
phonotactic profile lives next to the entity as a sibling YAML file
the engine reads when generating names.

Two MCP tools wired through tools/names.py: forge_culture and
generate_names, both visible to dm/seeder/planner. The arc_architect
intentionally doesn't get them — its surface stays at commit_arc +
recall. The seeder prompt now requires generate_names for every
named entity ("never type a fantasy name yourself"), and the DM and
planner prompts cover the same rule for new entities created during
play or enrichment.

PHOIBLE is the obvious next data source if we want richer or more
authentic inventories — flagged as the v0.2 plan in the README, not
part of v0.1. The current 25 hand-encoded inventories are tuned by
ear for fantasy output and they work; we can swap in real PHOIBLE
data once we've watched the generator in actual play.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+2885 -19
+7
prompts/dm-system.md
··· 23 23 - **Build the world** with `establish` (create or update entities), 24 24 `mark` (record history on an entity), `amend_mark` (fix the most 25 25 recent mark), `note_discovery` (record what the player learned). 26 + - **Name new entities** with `generate_names`, drawing from one of 27 + the cultures the seeder forged for this campaign. Use 28 + `recall(scope="world", content_type="cultures")` to find what's 29 + available, or `forge_culture` if the player travels somewhere new 30 + and you need a fresh culture. **Never type a fantasy name yourself 31 + for an NPC or place — always go through `generate_names`.** This 32 + is the only way to keep names from rhyming across campaigns. 26 33 - **Combat**: `enter_initiative` → (`next_turn`, `add_combatant`, 27 34 `remove_combatant`, `condition`, targeted `damage`/`heal`) → 28 35 `end_initiative`. Combat tools are hidden until initiative is active.
+10 -1
prompts/planner-system.md
··· 39 39 | Tool | Purpose | 40 40 |------|---------| 41 41 | `recall` | Look up rules or existing world content | 42 - | `establish` | Create or update entities (NPCs, locations, items, threads) | 42 + | `establish` | Create or update entities (NPCs, locations, items, threads, cultures) | 43 43 | `mark` | Record backstory events in an entity's history | 44 + | `forge_culture` | Forge a fresh culture if a thread or new region needs one | 45 + | `generate_names` | Generate names from a forged culture — use this for every new named entity | 44 46 | `notify_dm` | Send a brief summary of what you changed to the DM | 45 47 46 48 ## What to Do 49 + 50 + **Naming new entities is mandatory: always go through `generate_names`.** 51 + If you establish a new NPC or place during enrichment, never type the 52 + name yourself. Use `recall(scope="world", content_type="cultures")` to 53 + find this campaign's forged cultures, then call `generate_names` to 54 + draw from one of them. If the situation calls for a culture this 55 + campaign doesn't have yet, call `forge_culture` first. 47 56 48 57 For each thin entity you're given: 49 58
+61 -3
prompts/world-seed.md
··· 4 4 5 5 | Tool | Purpose | 6 6 |------|---------| 7 - | `establish` | Create entities (NPCs, locations, items, threads, lore) | 7 + | `forge_culture` | Procedurally generate a fresh culture (phonotactic profile + self-name) for this campaign | 8 + | `generate_names` | Generate names from a forged culture — every named NPC and place must come from here | 9 + | `establish` | Create entities (NPCs, locations, items, threads, lore, cultures) | 8 10 | `set_scene` | Place the player in the opening moment and set the starting time | 9 11 10 12 ## What You're Given ··· 30 32 31 33 If no Campaign Arc block is present (legacy worlds), fall back to honoring style.md alone. 32 34 35 + ## Naming Is Not Optional 36 + 37 + **Every named NPC and named location MUST be generated by 38 + `generate_names`. Never invent names yourself.** This is the 39 + single most important rule in this prompt — Claude has hot-path 40 + fantasy names ("Aldric Voss", "Mira", "Vera Blackwater") that 41 + appear in every campaign regardless of context, and the only way 42 + to escape that rut is to never type a name yourself. 43 + 44 + The flow: 45 + 46 + 1. **Forge 4-6 cultures first.** Before establishing any named 47 + entity, call `forge_culture` 4-6 times with different `feel` 48 + hints chosen to match the regions and peoples this campaign 49 + needs. Common feels: "coastal", "highland", "highborn", 50 + "industrial", "pastoral", "ancient", "forest", "desert", 51 + "steppe", "liturgical". You can also pass no feel for a fully 52 + random culture. Each `forge_culture` call returns the new 53 + culture's self-name and a few sample names so you can hear 54 + what it sounds like. 55 + 56 + 2. **Use `generate_names` for every named entity.** When you're 57 + ready to establish an NPC or a location, first call 58 + `generate_names(culture="<self-name>", count=5, gender=..., 59 + kind="person")` (or `kind="place"` for locations) and pick 60 + one of the returned names. Then `establish` the entity with 61 + that name. 62 + 63 + 3. **Tie cultures to regions via wikilinks.** When you establish 64 + a region, faction, or settlement, link to the culture that 65 + lives there in the description (e.g., "Home of the 66 + [[saerwood]] fenfolk"). The seeder doesn't need a special 67 + tool for this — wikilinks in entity descriptions are how the 68 + world graph already works. 69 + 70 + 4. **Flesh out culture entities like normal entities.** Each 71 + forged culture is a real world entity at 72 + `cultures/{self-name}.md`. Once forged, give it a description, 73 + Knows/Wants/Will, and wikilinks to its homeland via 74 + `establish(entity_type="cultures", name="<self-name>", ...)`. 75 + 76 + If you find yourself wanting to type a name directly into an 77 + `establish` call, **stop**. Forge a culture first if you haven't, 78 + then call `generate_names`. The whole point of this tool is to 79 + defeat the rut you don't even notice you're in. 80 + 33 81 ## What to Build 34 82 35 83 Work outward from the character's backstory: ··· 80 128 - `threads` — plot hooks and ongoing storylines 81 129 - `lore` — history, legends, world facts 82 130 - `maps` — spatial layouts worth persisting 131 + - `cultures` — peoples/cultures, forged via `forge_culture` 83 132 84 133 ## Visual Details 85 134 ··· 91 140 92 141 ## Order of Operations 93 142 94 - 1. `establish` all entities, starting with locations, then NPCs, then threads 95 - 2. `set_scene` to place the player in the opening moment — include `event="Day 1 begins. {Character name} arrives at {location}."` and `duration="0 min"` to start the clock 143 + 1. **`forge_culture` 4-6 times** with feel hints chosen to match the 144 + regions and peoples this campaign needs 145 + 2. **`generate_names` for every named entity** — call this before each 146 + `establish` so the name comes from a forged culture, never from 147 + your own typing 148 + 3. `establish` all entities, starting with locations, then NPCs, then 149 + threads. For each `cultures/` entity, fill in the body with the 150 + culture's description and Knows/Wants/Will. 151 + 4. `set_scene` to place the player in the opening moment — include 152 + `event="Day 1 begins. {Character name} arrives at {location}."` 153 + and `duration="0 min"` to start the clock 96 154 97 155 When you've built the world, stop.
+1 -1
src/storied/engine.py
··· 324 324 325 325 parts = ["## What You Know\n"] 326 326 327 - for content_type in ["npcs", "locations", "factions", "lore"]: 327 + for content_type in ["npcs", "locations", "factions", "cultures", "lore"]: 328 328 type_dir = knowledge_dir / content_type 329 329 if not type_dir.exists(): 330 330 continue
+2
src/storied/mcp_server.py
··· 25 25 combat, 26 26 entities, 27 27 mechanics, 28 + names, 28 29 run_code, 29 30 scene, 30 31 ) ··· 139 140 server.mount(entities.mcp) 140 141 server.mount(combat.mcp) 141 142 server.mount(run_code.mcp) 143 + server.mount(names.mcp) 142 144 143 145 # Hide tools tagged for other roles, then re-enable tools that also 144 146 # carry our role tag (e.g. update_character is in both `dm` and
+40
src/storied/names/AGENTS.md
··· 1 + # storied.names — Import Discipline 2 + 3 + This module is a self-contained phonotactic name generator. It is 4 + designed to be lifted out of storied into a standalone Python 5 + package (`phonomos` or similar) without modification. 6 + 7 + For attribution, prior art, and the design history, see `README.md`. 8 + 9 + ## Hard rules 10 + 11 + 1. **Nothing in `storied.names.*` may import from elsewhere in 12 + storied.** No `from storied.paths`, no `from storied.engine`, 13 + nothing. If you find yourself wanting to, the dependency belongs 14 + in the call site (`storied.tools.names`), not here. There is a 15 + test in `tests/test_names_discipline.py` that AST-scans this 16 + module and fails the build if anything sneaks in. 17 + 18 + 2. **Filesystem paths are passed in, not resolved.** `CultureForge` 19 + and `Generator` take a `world_path` argument. They do not look 20 + up paths via `storied.paths`. This keeps them usable from 21 + anywhere — tests, scripts, future contexts. 22 + 23 + 3. **No campaign-specific assumptions.** A "world" here is just a 24 + directory with a `cultures/` subdir. Nothing else from the 25 + storied world layout leaks in. 26 + 27 + 4. **Only `storied.tools.names` is allowed to import from 28 + `storied.names`.** That file is the bridge — it knows how to 29 + resolve world paths via `storied.paths`, instantiates the 30 + forge/generator, and exposes them as MCP tools. No other 31 + storied module should reach into `storied.names` directly. 32 + 33 + ## Why 34 + 35 + This module is the future `phonomos` package. Storied is its first 36 + user, not its only one. Keeping it self-contained means the day we 37 + extract it (or someone else wants to use it for a different game), 38 + the move is `git mv src/storied/names ../phonomos/src/phonomos` 39 + followed by updating the storied `tools/names.py` import path. 40 + Nothing else should need to change.
+108
src/storied/names/README.md
··· 1 + # storied.names 2 + 3 + A phonotactic fantasy-name generator. Forges procedural cultures 4 + per-campaign and produces internally-coherent names from each one, 5 + so the same campaign's names cluster phonotactically and different 6 + campaigns never rhyme with each other. 7 + 8 + This module is self-contained and is designed to be extracted into 9 + a standalone Python package (working name: `phonomos`) without 10 + modification. See `AGENTS.md` for the import discipline that makes 11 + extraction mechanical. 12 + 13 + ## How it fits together 14 + 15 + Three layers: 16 + 17 + 1. **Engine** (`engine/`) — phoneme inventories with Zipfian 18 + weights, syllable templates with optional slots, sonority 19 + sequencing, forbidden cluster rejection, orthographic rewrite 20 + rules. Assemble these into an `Engine` and call `name()`. 21 + 2. **Forge** (`forge.py`) — picks a random PHOIBLE-inspired 22 + inventory, trims it, picks templates and morphology, runs the 23 + assembled engine to produce the culture's own self-name as its 24 + first emission, and saves the profile to disk. 25 + 3. **Generator** (`generator.py`) — high-level API that loads 26 + forged cultures from a world directory and produces named 27 + people and places, with optional gender and rarity tuning. 28 + 29 + The storied integration lives in `src/storied/tools/names.py`, 30 + which exposes two MCP tools (`forge_culture`, `generate_names`). 31 + That adapter is the only file allowed to import from this module. 32 + 33 + ## Attribution 34 + 35 + This module is original code, but the **design** owes substantial 36 + debts to the conlang community and to academic phonology. None of 37 + the listed sources were ported or copied — they're the prior art 38 + this module learned from. 39 + 40 + ### Architecture 41 + 42 + - **Lexifer** by **William S. Annis** — the layered design 43 + (phoneme classes with Zipfian weights → syllable templates → 44 + sonority constraint → cluster rejection → orthographic rewrites) 45 + comes directly from Lexifer. The TypeScript port at 46 + `bbrk24/lexifer-ts` (MIT) was the cleanest reference for what 47 + the layers should look like. 48 + https://github.com/bbrk24/lexifer-ts 49 + - **Awkwords** by **Petr Mejzlík** — the `(C)V(C)` template DSL 50 + with parens for optional slots is the de-facto pattern from 51 + Awkwords. https://github.com/petrmejzlik/Awkwords 52 + - **Zompist's Gen / SCA²** by **Mark Rosenfelder** — the 53 + rewrite-rule-as-orthography-pass pattern. 54 + https://zompist.com/gen.html 55 + - **Logopoeist** by **gliese1337** — the conditional-distribution 56 + approach to phonotactics that informed the design choices around 57 + morphology generation. https://github.com/gliese1337/Logopoeist 58 + 59 + ### Linguistics 60 + 61 + - **Zipf's Law** (Zipf 1935) and the **Gusein-Zade distribution** 62 + (Gusein-Zade 1987) for phoneme rank-frequency — the basis for 63 + the weighted sampling in `engine/inventory.py`. 64 + - **The Sonority Sequencing Principle** (Selkirk 1984; Clements 65 + 1990) — the constraint enforced in `engine/sonority.py`. 66 + - **The conlang community's phoneme-class / syllable-template 67 + approach** — the conceptual model the engine uses, refined over 68 + decades by hobbyists and academics on places like the Zompist 69 + Bulletin Board and the conlang-l mailing list. 70 + 71 + ### Phoneme inventories 72 + 73 + The inventories in `data/inventories.py` are **hand-encoded 74 + simplifications inspired by real-world language families**. They 75 + are not transcriptions, not authoritative, and not pulled from 76 + any specific published dataset. Each one is a plausible-sounding 77 + phoneme set that captures the *flavor* of its source family 78 + without claiming to represent it accurately. 79 + 80 + The inventories were informed by general knowledge of: 81 + 82 + - **PHOIBLE** (Moran & McCloy 2019, CC-BY-SA) — the canonical 83 + cross-linguistic phoneme inventory database. Not used as a 84 + data source in this module, but the long-term goal is to pull 85 + richer inventories from PHOIBLE for v0.2. 86 + https://phoible.org 87 + - Various reference grammars and Wikipedia phonology articles for 88 + the source language families. 89 + 90 + If you take this module further — especially toward "linguist-grade" 91 + output — the right next step is to consume PHOIBLE directly rather 92 + than continuing to hand-encode inventories. 93 + 94 + ### What's NOT borrowed 95 + 96 + No code was ported or copied from any of the sources above. The 97 + syllable template parser, the sonority check, the cluster 98 + rejection, the engine assembly, the forge logic, the suffix 99 + generation — all original. Any bugs are mine, and any quality 100 + ceiling is mine, not the prior art's. 101 + 102 + ### Further reading 103 + 104 + If you want to understand the conlang-tool tradition this module 105 + sits inside, the canonical starting points are Mark Rosenfelder's 106 + *Language Construction Kit* (https://zompist.com/kitlong.html) and 107 + Colin Gorrie's articles on word-generation tools 108 + (https://colingorrie.com/articles/conlang-word-generator/).
+23
src/storied/names/__init__.py
··· 1 + """storied.names — phonotactic name generator. 2 + 3 + Public API: 4 + PhonemeInventory — phoneme classes with weights 5 + Engine — assembled phonotactic engine for a single culture 6 + CultureForge — procedural per-campaign culture generation 7 + Generator — high-level API: load saved cultures and produce names 8 + 9 + This module is self-contained. See AGENTS.md for the import discipline. 10 + """ 11 + 12 + from storied.names.engine.generator import Engine 13 + from storied.names.engine.inventory import PhonemeInventory 14 + from storied.names.forge import CultureForge, ForgedCulture 15 + from storied.names.generator import Generator 16 + 17 + __all__ = [ 18 + "CultureForge", 19 + "Engine", 20 + "ForgedCulture", 21 + "Generator", 22 + "PhonemeInventory", 23 + ]
+14
src/storied/names/data/__init__.py
··· 1 + """Hand-curated reference data for the phonotactic engine. 2 + 3 + `inventories` holds ~25 phoneme inventories inspired by real-world 4 + languages. They are NOT meant to be linguistically authoritative — 5 + they're meant to be phonotactically distinctive enough that no two 6 + forged cultures sound alike. 7 + 8 + `templates` holds a pool of syllable templates and morphology hints 9 + the forge draws from at random. 10 + 11 + `feels` holds a small registry of "feel hints" — soft archetype 12 + labels (coastal, highborn, industrial, etc.) that the forge can 13 + use to bias inventory selection. 14 + """
+407
src/storied/names/data/inventories.py
··· 1 + """Phoneme inventories inspired by real-world languages. 2 + 3 + These are NOT authoritative transcriptions of any specific 4 + language. They're plausible phoneme sets, hand-encoded from 5 + general knowledge of each source family — informed by reference 6 + grammars, Wikipedia phonology articles, and the author's reading. 7 + They are NOT pulled from PHOIBLE (Moran & McCloy 2019, 8 + https://phoible.org), though PHOIBLE is the obvious next data 9 + source for v0.2 if we want richer inventories. See README.md 10 + for full attribution. 11 + 12 + The goal: enough variety that random forging produces cultures 13 + that don't sound like each other. 14 + 15 + The "feels" tag is a soft archetype label the forge uses to bias 16 + selection. A culture forged with feel="coastal" will preferentially 17 + draw from inventories tagged with that feel. 18 + 19 + Each inventory's phoneme list is in roughly Zipfian order — the 20 + first entries are the most common in the source language, so the 21 + engine's Gusein-Zade weighting picks them up first. 22 + """ 23 + 24 + from __future__ import annotations 25 + 26 + from storied.names.engine.inventory import PhonemeInventory 27 + 28 + # Each entry: (PhonemeInventory, set of feels it suits) 29 + INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ 30 + # ----- Celtic / British Isles family -------------------------------- 31 + ( 32 + PhonemeInventory( 33 + name="welsh", 34 + consonants=[ 35 + "n", "r", "l", "d", "s", "h", "g", "m", "k", 36 + "th", "dh", "rh", "ll", "f", "p", "t", "b", "ch", "w", 37 + ], 38 + vowels=["a", "e", "i", "o", "u", "y", "ae", "wy", "aw"], 39 + liquids=["l", "r", "ll", "rh"], 40 + nasals=["n", "m"], 41 + fricatives=["s", "h", "th", "dh", "f", "ch"], 42 + ), 43 + {"coastal", "highland", "pastoral", "ancient"}, 44 + ), 45 + ( 46 + PhonemeInventory( 47 + name="gaelic", 48 + consonants=[ 49 + "n", "r", "l", "s", "d", "t", "g", "b", "m", "k", 50 + "ch", "mh", "bh", "f", "p", "h", "th", 51 + ], 52 + vowels=["a", "i", "e", "o", "u", "ai", "ei", "io", "ua"], 53 + liquids=["l", "r"], 54 + nasals=["n", "m", "mh"], 55 + fricatives=["s", "h", "ch", "th", "bh", "mh", "f"], 56 + ), 57 + {"highland", "pastoral", "ancient", "highborn"}, 58 + ), 59 + ( 60 + PhonemeInventory( 61 + name="cornish", 62 + consonants=[ 63 + "n", "r", "l", "d", "s", "t", "k", "g", "m", "p", 64 + "h", "w", "th", "gh", "f", "v", 65 + ], 66 + vowels=["a", "e", "i", "o", "u", "y", "oe", "eu"], 67 + liquids=["l", "r"], 68 + nasals=["n", "m"], 69 + fricatives=["s", "h", "th", "gh", "f", "v"], 70 + ), 71 + {"coastal", "pastoral", "ancient"}, 72 + ), 73 + 74 + # ----- Germanic family ---------------------------------------------- 75 + ( 76 + PhonemeInventory( 77 + name="old-norse", 78 + consonants=[ 79 + "r", "n", "s", "t", "k", "l", "d", "g", "m", "h", 80 + "th", "v", "f", "p", "b", "j", 81 + ], 82 + vowels=["a", "i", "e", "u", "o", "ø", "y", "ei", "au"], 83 + liquids=["r", "l"], 84 + nasals=["n", "m"], 85 + fricatives=["s", "h", "th", "v", "f"], 86 + ), 87 + {"coastal", "highborn", "ancient", "industrial"}, 88 + ), 89 + ( 90 + PhonemeInventory( 91 + name="old-english", 92 + consonants=[ 93 + "n", "r", "s", "t", "l", "d", "h", "w", "k", "g", 94 + "m", "th", "f", "b", "p", "sh", 95 + ], 96 + vowels=["a", "e", "i", "o", "u", "y", "ae", "ea", "eo"], 97 + liquids=["r", "l"], 98 + nasals=["n", "m"], 99 + fricatives=["s", "h", "th", "f", "sh"], 100 + ), 101 + {"pastoral", "ancient", "industrial"}, 102 + ), 103 + ( 104 + PhonemeInventory( 105 + name="germanic-soot", 106 + consonants=[ 107 + "r", "n", "s", "t", "k", "l", "h", "g", "d", "m", 108 + "f", "p", "b", "ch", "z", "sh", 109 + ], 110 + vowels=["a", "e", "i", "o", "u", "ä", "ö", "ü"], 111 + liquids=["r", "l"], 112 + nasals=["n", "m"], 113 + fricatives=["s", "h", "ch", "f", "z", "sh"], 114 + ), 115 + {"industrial", "highborn"}, 116 + ), 117 + 118 + # ----- Romance / Latin-derived -------------------------------------- 119 + ( 120 + PhonemeInventory( 121 + name="latin-clerical", 122 + consonants=[ 123 + "s", "n", "r", "t", "l", "i", "k", "m", "d", "p", 124 + "g", "b", "f", "v", "h", 125 + ], 126 + vowels=["a", "e", "i", "o", "u", "ae", "oe", "au"], 127 + liquids=["l", "r"], 128 + nasals=["n", "m"], 129 + fricatives=["s", "f", "v", "h"], 130 + ), 131 + {"highborn", "ancient", "liturgical"}, 132 + ), 133 + ( 134 + PhonemeInventory( 135 + name="iberian", 136 + consonants=[ 137 + "r", "n", "s", "l", "t", "d", "k", "m", "g", "p", 138 + "ch", "ñ", "ll", "j", "f", "rr", 139 + ], 140 + vowels=["a", "e", "i", "o", "u"], 141 + liquids=["l", "r", "ll", "rr"], 142 + nasals=["n", "m", "ñ"], 143 + fricatives=["s", "ch", "j", "f"], 144 + ), 145 + {"coastal", "pastoral", "highborn"}, 146 + ), 147 + 148 + # ----- Slavic family ------------------------------------------------ 149 + ( 150 + PhonemeInventory( 151 + name="slavic-east", 152 + consonants=[ 153 + "n", "r", "s", "t", "l", "k", "d", "v", "m", "p", 154 + "z", "g", "ch", "sh", "zh", "h", 155 + ], 156 + vowels=["a", "o", "e", "i", "u", "y", "ya", "yu"], 157 + liquids=["l", "r"], 158 + nasals=["n", "m"], 159 + fricatives=["s", "v", "z", "sh", "zh", "h", "ch"], 160 + ), 161 + {"highland", "pastoral", "ancient"}, 162 + ), 163 + 164 + # ----- Semitic / Levantine ------------------------------------------ 165 + ( 166 + PhonemeInventory( 167 + name="aramaic", 168 + consonants=[ 169 + "l", "n", "r", "m", "s", "t", "k", "h", "d", "b", 170 + "sh", "kh", "th", "q", "ts", "p", "z", "gh", 171 + ], 172 + vowels=["a", "i", "u", "e", "o"], 173 + liquids=["l", "r"], 174 + nasals=["n", "m"], 175 + fricatives=["s", "h", "sh", "kh", "th", "z", "gh"], 176 + ), 177 + {"liturgical", "ancient", "highborn", "desert"}, 178 + ), 179 + ( 180 + PhonemeInventory( 181 + name="arabic-port", 182 + consonants=[ 183 + "l", "r", "n", "m", "s", "t", "k", "h", "d", "b", 184 + "sh", "kh", "q", "j", "z", "f", "gh", "ts", 185 + ], 186 + vowels=["a", "i", "u", "aa", "ii", "uu"], 187 + liquids=["l", "r"], 188 + nasals=["n", "m"], 189 + fricatives=["s", "h", "sh", "kh", "z", "f", "gh"], 190 + ), 191 + {"coastal", "desert", "ancient"}, 192 + ), 193 + 194 + # ----- Hellenic ----------------------------------------------------- 195 + ( 196 + PhonemeInventory( 197 + name="hellenic", 198 + consonants=[ 199 + "s", "n", "r", "t", "l", "k", "p", "m", "d", "th", 200 + "ph", "ch", "h", "g", "x", "ps", 201 + ], 202 + vowels=["a", "o", "e", "i", "u", "ai", "oi", "eu"], 203 + liquids=["l", "r"], 204 + nasals=["n", "m"], 205 + fricatives=["s", "th", "ph", "ch", "h", "x"], 206 + ), 207 + {"coastal", "highborn", "liturgical", "ancient"}, 208 + ), 209 + 210 + # ----- Uralic / Finnic ---------------------------------------------- 211 + ( 212 + PhonemeInventory( 213 + name="finnic", 214 + consonants=[ 215 + "n", "l", "t", "s", "k", "r", "i", "m", "h", "p", 216 + "v", "j", "y", 217 + ], 218 + vowels=["a", "i", "e", "o", "u", "ä", "ö", "y", "ai", "äi"], 219 + liquids=["l", "r"], 220 + nasals=["n", "m"], 221 + fricatives=["s", "h", "v"], 222 + ), 223 + {"forest", "pastoral", "ancient"}, 224 + ), 225 + 226 + # ----- Turkic / Steppe ---------------------------------------------- 227 + ( 228 + PhonemeInventory( 229 + name="turkic", 230 + consonants=[ 231 + "n", "r", "l", "t", "k", "s", "m", "d", "y", "b", 232 + "g", "sh", "ch", "z", "h", 233 + ], 234 + vowels=["a", "e", "i", "o", "u", "ı", "ö", "ü"], 235 + liquids=["l", "r"], 236 + nasals=["n", "m"], 237 + fricatives=["s", "sh", "z", "h"], 238 + ), 239 + {"steppe", "pastoral", "highland"}, 240 + ), 241 + ( 242 + PhonemeInventory( 243 + name="mongolic", 244 + consonants=[ 245 + "n", "r", "l", "g", "t", "k", "s", "d", "m", "b", 246 + "y", "kh", "ch", "j", "h", 247 + ], 248 + vowels=["a", "o", "u", "e", "i"], 249 + liquids=["l", "r"], 250 + nasals=["n", "m"], 251 + fricatives=["s", "kh", "h"], 252 + ), 253 + {"steppe", "ancient", "pastoral"}, 254 + ), 255 + 256 + # ----- Caucasian --------------------------------------------------- 257 + ( 258 + PhonemeInventory( 259 + name="kartvelian", 260 + consonants=[ 261 + "r", "n", "l", "t", "k", "s", "m", "d", "g", "b", 262 + "ts", "ch", "kh", "ph", "q", "z", "v", "sh", 263 + ], 264 + vowels=["a", "e", "i", "o", "u"], 265 + liquids=["l", "r"], 266 + nasals=["n", "m"], 267 + fricatives=["s", "z", "sh", "kh", "v"], 268 + ), 269 + {"highland", "ancient", "highborn"}, 270 + ), 271 + 272 + # ----- Pacific ----------------------------------------------------- 273 + ( 274 + PhonemeInventory( 275 + name="polynesian", 276 + consonants=["n", "k", "l", "h", "m", "p", "t", "w", "ng"], 277 + vowels=["a", "i", "u", "o", "e", "ai", "au", "ei"], 278 + liquids=["l"], 279 + nasals=["n", "m", "ng"], 280 + fricatives=["h"], 281 + ), 282 + {"coastal", "forest", "ancient"}, 283 + ), 284 + ( 285 + PhonemeInventory( 286 + name="austronesian", 287 + consonants=[ 288 + "n", "t", "k", "l", "s", "m", "r", "p", "d", "g", 289 + "ng", "h", "w", "y", "b", 290 + ], 291 + vowels=["a", "i", "u", "e", "o"], 292 + liquids=["l", "r"], 293 + nasals=["n", "m", "ng"], 294 + fricatives=["s", "h"], 295 + ), 296 + {"coastal", "forest", "pastoral"}, 297 + ), 298 + 299 + # ----- East Asian -------------------------------------------------- 300 + ( 301 + PhonemeInventory( 302 + name="japonic", 303 + consonants=[ 304 + "n", "k", "t", "s", "r", "m", "h", "g", "d", "b", 305 + "y", "w", "z", "p", "sh", "ch", 306 + ], 307 + vowels=["a", "i", "u", "e", "o"], 308 + liquids=["r"], 309 + nasals=["n", "m"], 310 + fricatives=["s", "sh", "h", "z"], 311 + ), 312 + {"coastal", "highborn", "ancient"}, 313 + ), 314 + 315 + # ----- South Asian ------------------------------------------------- 316 + ( 317 + PhonemeInventory( 318 + name="dravidian", 319 + consonants=[ 320 + "n", "r", "t", "k", "l", "m", "p", "v", "y", "ch", 321 + "th", "d", "g", "ng", "ny", "zh", 322 + ], 323 + vowels=["a", "i", "u", "e", "o", "aa", "ii", "uu"], 324 + liquids=["l", "r", "zh"], 325 + nasals=["n", "m", "ng", "ny"], 326 + fricatives=["v", "th"], 327 + ), 328 + {"forest", "ancient", "highborn"}, 329 + ), 330 + 331 + # ----- Andean ------------------------------------------------------ 332 + ( 333 + PhonemeInventory( 334 + name="quechuan", 335 + consonants=[ 336 + "n", "k", "t", "p", "s", "r", "m", "ch", "y", "w", 337 + "l", "h", "ll", "q", "kh", "ph", 338 + ], 339 + vowels=["a", "i", "u"], 340 + liquids=["l", "r", "ll"], 341 + nasals=["n", "m"], 342 + fricatives=["s", "h"], 343 + ), 344 + {"highland", "ancient", "pastoral"}, 345 + ), 346 + 347 + # ----- Mesoamerican ------------------------------------------------ 348 + ( 349 + PhonemeInventory( 350 + name="nahuatl", 351 + consonants=[ 352 + "n", "t", "k", "l", "s", "ch", "m", "y", "w", "p", 353 + "tz", "x", "h", "ts", "tl", 354 + ], 355 + vowels=["a", "i", "e", "o", "u"], 356 + liquids=["l"], 357 + nasals=["n", "m"], 358 + fricatives=["s", "x", "h"], 359 + ), 360 + {"forest", "highland", "ancient"}, 361 + ), 362 + 363 + # ----- West African ------------------------------------------------ 364 + ( 365 + PhonemeInventory( 366 + name="yoruboid", 367 + consonants=[ 368 + "n", "l", "r", "k", "b", "t", "s", "m", "y", "g", 369 + "p", "f", "j", "w", "sh", 370 + ], 371 + vowels=["a", "e", "i", "o", "u", "ẹ", "ọ"], 372 + liquids=["l", "r"], 373 + nasals=["n", "m"], 374 + fricatives=["s", "f", "sh"], 375 + ), 376 + {"forest", "coastal", "pastoral"}, 377 + ), 378 + ( 379 + PhonemeInventory( 380 + name="bantoid", 381 + consonants=[ 382 + "n", "m", "l", "k", "t", "b", "s", "g", "p", "d", 383 + "w", "y", "z", "v", "ng", "ny", 384 + ], 385 + vowels=["a", "i", "u", "e", "o"], 386 + liquids=["l"], 387 + nasals=["n", "m", "ng", "ny"], 388 + fricatives=["s", "v", "z"], 389 + ), 390 + {"forest", "pastoral", "highland"}, 391 + ), 392 + ] 393 + 394 + 395 + def all_inventories() -> list[PhonemeInventory]: 396 + """Return every inventory, untagged.""" 397 + return [inv for inv, _ in INVENTORIES] 398 + 399 + 400 + def inventories_for_feel(feel: str) -> list[PhonemeInventory]: 401 + """Return inventories tagged with the given feel. 402 + 403 + Falls back to all inventories if no inventories match the feel 404 + (so callers always get a non-empty list). 405 + """ 406 + matches = [inv for inv, feels in INVENTORIES if feel in feels] 407 + return matches if matches else all_inventories()
+36
src/storied/names/data/templates.py
··· 1 + """Syllable template pools the forge draws from. 2 + 3 + Templates use the syllable parser DSL: `(C)V(C)`, `C(L)V`, etc. 4 + Each "template set" is a small list of templates a culture uses 5 + together — the engine picks one per syllable. Mixing templates 6 + gives words a less-uniform feel than a single template would. 7 + 8 + Morphology and place suffixes are NOT in fixed banks — they're 9 + generated per-culture by the forge using each culture's own 10 + phonotactic engine, so they sound coherent with the rest of the 11 + culture's names. See `forge.py` for that. 12 + """ 13 + 14 + from __future__ import annotations 15 + 16 + # Each template set is a coherent group; the forge picks one set 17 + # per culture rather than mix-and-matching across sets. Templates 18 + # bias toward (C)V and CV(C) shapes since those are the most common 19 + # syllable types cross-linguistically and produce the most 20 + # pronounceable output. 21 + TEMPLATE_SETS: list[list[str]] = [ 22 + ["(C)V(C)", "(C)V"], 23 + ["CV(C)", "CV"], 24 + ["(C)V(N)", "(C)V"], 25 + ["C(L)V(C)", "CV"], 26 + ["(C)V(C)", "CV"], 27 + ["CV", "CVC"], 28 + ["(C)VC", "(C)V"], 29 + ["CV", "CVC", "(C)V"], 30 + ["(C)V(F)", "(C)V"], 31 + ["(C)VC", "(C)V(C)"], 32 + ["C(L)V", "CV(N)"], 33 + ["(C)V(C)", "C(L)V"], 34 + ["CVC", "CV"], 35 + ["(C)V(C)", "C(L)V(C)", "(C)V"], 36 + ]
+10
src/storied/names/engine/__init__.py
··· 1 + """Phonotactic engine: phoneme inventories, syllable templates, generation. 2 + 3 + Layered like Lexifer (William Annis): 4 + inventory — phoneme classes with Zipfian weights 5 + syllable — template parser + sampler 6 + sonority — sonority sequencing constraint 7 + clusters — forbidden cluster rejection 8 + rewrite — orthography rewrite rules 9 + generator — the assembled engine 10 + """
+48
src/storied/names/engine/clusters.py
··· 1 + """Forbidden cluster rejection. 2 + 3 + Some phoneme combinations are unpronounceable or visually ugly 4 + in any orthographic representation. The forge picks a small set 5 + of forbidden clusters per culture (some random, some derived from 6 + the inventory) and the engine rejects any generated word that 7 + contains one. 8 + 9 + This is a simple substring check on the joined phoneme string, 10 + not on individual phonemes. Patterns can include `#` to anchor 11 + to word boundary (`#sr` = no /sr/ at word start; `tl#` = no /tl/ 12 + at word end). 13 + """ 14 + 15 + from __future__ import annotations 16 + 17 + 18 + def normalize_word(phonemes: list[str]) -> str: 19 + """Join phonemes into a single string for substring matching.""" 20 + return "".join(phonemes) 21 + 22 + 23 + def has_forbidden_cluster(word: str, forbidden: list[str]) -> bool: 24 + """True if any forbidden pattern appears in the word. 25 + 26 + `#` in a pattern anchors to a word boundary. Word boundaries 27 + are added implicitly to both ends of `word` for matching. 28 + """ 29 + if not forbidden: 30 + return False 31 + bounded = f"#{word}#" 32 + return any(pattern in bounded for pattern in forbidden) 33 + 34 + 35 + # A small bank of generally-ugly cluster patterns the forge can 36 + # draw from when picking forbidden lists. Not exhaustive — just 37 + # enough to ensure each culture rejects a few combinations and 38 + # they vary across cultures. 39 + FORBIDDEN_BANK: list[str] = [ 40 + "#sr", "#tl", "#sl", "#dl", "#tn", "#mn", "#km", "#nl", "#nr", 41 + "#ml", "#mr", "#mlr", "#lr", "#rl", "#nz", "#kn", "#gn", 42 + "tl#", "rl#", "lr#", "nm#", "mn#", "ml#", "mr#", "nl#", 43 + "sshs", "tttt", "kkk", "rrrr", "llll", "ghgh", "khkh", 44 + "tlt", "rlr", "lrl", "nmn", "mnm", "rnr", "lnl", 45 + "qq", "xx", "zz", "ngng", "ththth", 46 + "ghgh", "ghgh", "mlm", "mrm", "rlr", 47 + "mlm", "mrm", "rlw", "rly", 48 + ]
+103
src/storied/names/engine/generator.py
··· 1 + """Engine: assembled phonotactic generator for a single culture. 2 + 3 + Takes an inventory, syllable templates, forbidden clusters, rewrite 4 + rules, and a few options. Produces names by sampling templates, 5 + checking sonority and clusters, applying rewrites, and capitalizing. 6 + 7 + This is the heart of the name generator. The CultureForge wraps it 8 + to randomize the inputs per culture; the high-level Generator API 9 + loads saved cultures and runs Engine for each one. 10 + """ 11 + 12 + from __future__ import annotations 13 + 14 + import random 15 + from dataclasses import dataclass, field 16 + 17 + from storied.names.engine.clusters import has_forbidden_cluster, normalize_word 18 + from storied.names.engine.inventory import PhonemeInventory 19 + from storied.names.engine.rewrite import apply_rules, capitalize_name 20 + from storied.names.engine.sonority import passes_sonority, sonority_class 21 + from storied.names.engine.syllable import Slot, parse_template, sample_word 22 + 23 + 24 + def _has_vowel(phonemes: list[str]) -> bool: 25 + """True if the sequence contains at least one vowel-class phoneme.""" 26 + return any(sonority_class(p) == 8 for p in phonemes) 27 + 28 + 29 + @dataclass 30 + class Engine: 31 + """A configured phonotactic engine for one culture. 32 + 33 + The engine is deterministic given a seed: pass the same seed to 34 + `name(seed=...)` and you get the same output. The forge uses 35 + this to give every culture a stable self-name derived from its 36 + forge seed. 37 + """ 38 + 39 + inventory: PhonemeInventory 40 + templates: list[str] 41 + forbidden_clusters: list[str] = field(default_factory=list) 42 + rewrite_rules: list[str] = field(default_factory=list) 43 + syllable_count: tuple[int, int] = (2, 3) 44 + min_length: int = 3 45 + max_length: int = 11 46 + _parsed: list[list[Slot]] = field(init=False, default_factory=list) 47 + 48 + def __post_init__(self) -> None: 49 + self._parsed = [parse_template(t) for t in self.templates] 50 + 51 + def _emit_phonemes(self, rng: random.Random, max_attempts: int = 80) -> list[str]: 52 + """Sample a phoneme sequence that passes all the engine's checks.""" 53 + for _ in range(max_attempts): 54 + phonemes = sample_word( 55 + self._parsed, self.inventory, rng, self.syllable_count, 56 + ) 57 + if not phonemes: 58 + continue 59 + word = normalize_word(phonemes) 60 + if not (self.min_length <= len(word) <= self.max_length): 61 + continue 62 + if not _has_vowel(phonemes): 63 + continue 64 + if not passes_sonority(phonemes): 65 + continue 66 + if has_forbidden_cluster(word, self.forbidden_clusters): 67 + continue 68 + return phonemes 69 + # Fallback: relax constraints rather than loop forever 70 + return sample_word( 71 + self._parsed, self.inventory, rng, self.syllable_count, 72 + ) 73 + 74 + def name(self, seed: int | None = None) -> str: 75 + """Generate a single name. Pass `seed` for reproducible output.""" 76 + rng = random.Random(seed) if seed is not None else random.Random() 77 + return self._format(self._emit_phonemes(rng)) 78 + 79 + def names(self, count: int, seed: int | None = None) -> list[str]: 80 + """Generate `count` distinct names.""" 81 + rng = random.Random(seed) if seed is not None else random.Random() 82 + seen: set[str] = set() 83 + out: list[str] = [] 84 + attempts = 0 85 + max_attempts = count * 20 86 + while len(out) < count and attempts < max_attempts: 87 + phonemes = self._emit_phonemes(rng) 88 + name = self._format(phonemes) 89 + if name and name not in seen: 90 + seen.add(name) 91 + out.append(name) 92 + attempts += 1 93 + return out 94 + 95 + def _format(self, phonemes: list[str]) -> str: 96 + """Apply rewrite rules and capitalization to a phoneme sequence.""" 97 + word = normalize_word(phonemes) 98 + word = apply_rules(word, self.rewrite_rules) 99 + return capitalize_name(word) 100 + 101 + 102 + # Re-export sample_word so the syllable.py public surface stays useful 103 + __all__ = ["Engine", "sample_word"]
+120
src/storied/names/engine/inventory.py
··· 1 + """Phoneme inventory with Zipfian frequency weighting. 2 + 3 + A `PhonemeInventory` holds named classes of phonemes (consonants, 4 + vowels, liquids, nasals, etc.) and assigns each phoneme a weight 5 + based on its position in its class. First-listed = most frequent, 6 + following the Gusein-Zade / Zipfian distribution that natural 7 + languages exhibit. 8 + 9 + Sampling from a class draws weighted-random; this is what gives 10 + each culture a distinctive "sound" — a few phonemes recur often, 11 + others are rare flavor. 12 + 13 + Background: Zipf 1935 (Zipfian rank-frequency); Gusein-Zade 1987 14 + (closer fit for phoneme inventories specifically). The 15 + weighted-class-sampling approach is from William Annis's Lexifer. 16 + """ 17 + 18 + from __future__ import annotations 19 + 20 + import random 21 + from dataclasses import dataclass, field 22 + 23 + 24 + def zipfian_weights(n: int) -> list[float]: 25 + """Weights for `n` items following p(rank k) ∝ 1/k. 26 + 27 + Returns a list of length `n` summing to 1.0. The first item is 28 + most frequent; the last is rarest. This is the Gusein-Zade 29 + distribution Lexifer defaults to and that natural-language 30 + phoneme inventories tend to exhibit. 31 + """ 32 + if n <= 0: 33 + return [] 34 + raw = [1.0 / (k + 1) for k in range(n)] 35 + total = sum(raw) 36 + return [w / total for w in raw] 37 + 38 + 39 + @dataclass 40 + class PhonemeInventory: 41 + """A culture's phoneme inventory and class weights. 42 + 43 + `consonants` and `vowels` are the two required classes. The 44 + optional classes (`liquids`, `nasals`, `fricatives`, `stops`) 45 + are subsets used by syllable templates that want a specific 46 + sonority slot — for example a `(C)(L)V(C)` template needs a 47 + liquid in the L slot, drawn from `liquids` if present or 48 + falling back to a hard-coded heuristic on `consonants`. 49 + """ 50 + 51 + name: str 52 + consonants: list[str] 53 + vowels: list[str] 54 + liquids: list[str] = field(default_factory=list) 55 + nasals: list[str] = field(default_factory=list) 56 + fricatives: list[str] = field(default_factory=list) 57 + 58 + def classes(self) -> dict[str, list[str]]: 59 + """Map class-letter → phoneme list. Used by the syllable parser.""" 60 + return { 61 + "C": self.consonants, 62 + "V": self.vowels, 63 + "L": self.liquids or _fallback_liquids(self.consonants), 64 + "N": self.nasals or _fallback_nasals(self.consonants), 65 + "F": self.fricatives or _fallback_fricatives(self.consonants), 66 + } 67 + 68 + def sample(self, klass: str, rng: random.Random) -> str: 69 + """Draw a single phoneme from the named class with Zipfian weights.""" 70 + items = self.classes().get(klass) 71 + if not items: 72 + raise ValueError(f"Inventory '{self.name}' has no '{klass}' class") 73 + weights = zipfian_weights(len(items)) 74 + return rng.choices(items, weights=weights, k=1)[0] 75 + 76 + def trim(self, rng: random.Random, drop_fraction: float = 0.15) -> PhonemeInventory: 77 + """Return a smaller variant with some phonemes randomly removed. 78 + 79 + Used by the forge to make each culture phonotactically distinct 80 + from its source inventory — a Welsh-derived culture is not Welsh, 81 + it's a phonotactic cousin. 82 + """ 83 + def _trim(items: list[str]) -> list[str]: 84 + if len(items) <= 3: 85 + return list(items) 86 + drop_n = max(0, int(round(len(items) * drop_fraction))) 87 + keep = list(items) 88 + for _ in range(drop_n): 89 + idx = rng.randrange(len(keep)) 90 + keep.pop(idx) 91 + return keep 92 + 93 + return PhonemeInventory( 94 + name=f"{self.name}-trimmed", 95 + consonants=_trim(self.consonants), 96 + vowels=_trim(self.vowels) if len(self.vowels) > 4 else list(self.vowels), 97 + liquids=[c for c in self.liquids if c in _trim(self.consonants)], 98 + nasals=[c for c in self.nasals if c in _trim(self.consonants)], 99 + fricatives=[c for c in self.fricatives if c in _trim(self.consonants)], 100 + ) 101 + 102 + 103 + _LIQUID_HEURISTIC = {"l", "r", "ll", "rr", "rh", "lh", "ɾ", "ʎ", "ʟ"} 104 + _NASAL_HEURISTIC = {"m", "n", "ng", "ñ", "ŋ", "nh", "mh"} 105 + _FRICATIVE_HEURISTIC = { 106 + "s", "z", "f", "v", "sh", "zh", "th", "dh", "h", "x", "ch", 107 + "ʃ", "ʒ", "θ", "ð", "χ", "ɣ", "ħ", 108 + } 109 + 110 + 111 + def _fallback_liquids(consonants: list[str]) -> list[str]: 112 + return [c for c in consonants if c in _LIQUID_HEURISTIC] 113 + 114 + 115 + def _fallback_nasals(consonants: list[str]) -> list[str]: 116 + return [c for c in consonants if c in _NASAL_HEURISTIC] 117 + 118 + 119 + def _fallback_fricatives(consonants: list[str]) -> list[str]: 120 + return [c for c in consonants if c in _FRICATIVE_HEURISTIC]
+45
src/storied/names/engine/rewrite.py
··· 1 + """Post-generation orthography rewrite rules. 2 + 3 + After the engine produces a phoneme sequence, rewrite rules turn 4 + it into the final spelling. Two flavors: 5 + 6 + "ʃ -> sh" simple substitution 7 + "ng$ -> n" regex with $ anchor for word end 8 + "^h -> " regex with ^ anchor for word start 9 + 10 + Rules are applied in order. Each rule is a single substitution. 11 + The forge picks a small set of rules per culture from a bank, 12 + keeping orthography idiosyncratic across cultures. 13 + """ 14 + 15 + from __future__ import annotations 16 + 17 + import re 18 + 19 + 20 + def parse_rule(rule: str) -> tuple[re.Pattern[str], str]: 21 + """Parse a `pattern -> replacement` rule into (compiled regex, repl).""" 22 + if "->" not in rule: 23 + raise ValueError(f"Rewrite rule missing '->': {rule!r}") 24 + pattern_str, _, repl = rule.partition("->") 25 + pattern_str = pattern_str.strip() 26 + repl = repl.strip() 27 + return re.compile(pattern_str), repl 28 + 29 + 30 + def apply_rules(word: str, rules: list[str]) -> str: 31 + """Apply a list of `pattern -> replacement` rules in order.""" 32 + for rule in rules: 33 + try: 34 + pat, repl = parse_rule(rule) 35 + except (ValueError, re.error): 36 + continue 37 + word = pat.sub(repl, word) 38 + return word 39 + 40 + 41 + def capitalize_name(word: str) -> str: 42 + """Capitalize the first character. Names are personal nouns.""" 43 + if not word: 44 + return word 45 + return word[0].upper() + word[1:]
+97
src/storied/names/engine/sonority.py
··· 1 + """Sonority sequencing constraint. 2 + 3 + Implements the **Sonority Sequencing Principle** (Selkirk 1984; 4 + Clements 1990) — within a syllable, sonority should rise toward 5 + the nucleus (vowel) and fall away from it. So /tra/ is fine 6 + (stop → liquid → vowel, all rising), but /rta/ is bad at syllable 7 + start (liquid → stop → vowel, falling-then-rising, which natural 8 + languages avoid). 9 + 10 + This module assigns each phoneme a sonority class and provides a 11 + predicate that rejects words whose phoneme sequence violates 12 + sonority sequencing in obvious ways. 13 + 14 + The class numbers come from a standard sonority hierarchy 15 + (low to high): 16 + 17 + 1 voiceless stops: p t k 18 + 2 voiced stops: b d g 19 + 3 voiceless fricatives: f s sh th h x 20 + 4 voiced fricatives: v z zh dh 21 + 5 nasals: m n ng 22 + 6 liquids: l r 23 + 7 glides: w y j 24 + 8 vowels: a e i o u 25 + 26 + We're string-based, so the lookup is by phoneme literal. Unknown 27 + phonemes default to class 4 (mid-sonority); the constraint is 28 + soft enough that the unknown case isn't worth blocking. 29 + """ 30 + 31 + from __future__ import annotations 32 + 33 + _SONORITY: dict[str, int] = {} 34 + 35 + # Voiceless stops 36 + for p in ("p", "t", "k", "ʔ", "q", "kw", "tʼ", "kʼ", "pʼ", "c"): 37 + _SONORITY[p] = 1 38 + # Voiced stops 39 + for p in ("b", "d", "g", "gw", "ɖ", "ɟ"): 40 + _SONORITY[p] = 2 41 + # Affricates 42 + for p in ("ts", "tʃ", "ch", "tz", "dʒ", "j"): 43 + _SONORITY[p] = 2 44 + # Voiceless fricatives 45 + for p in ("f", "s", "sh", "ʃ", "th", "θ", "h", "x", "χ", "ħ", "ç", "ɸ"): 46 + _SONORITY[p] = 3 47 + # Voiced fricatives 48 + for p in ("v", "z", "zh", "ʒ", "dh", "ð", "ɣ", "ʕ", "β"): 49 + _SONORITY[p] = 4 50 + # Nasals 51 + for p in ("m", "n", "ng", "ŋ", "ñ", "ɲ", "nh", "mh", "nw"): 52 + _SONORITY[p] = 5 53 + # Liquids 54 + for p in ("l", "r", "ll", "rr", "rh", "lh", "ɾ", "ɹ", "ʎ", "ʟ", "ɬ"): 55 + _SONORITY[p] = 6 56 + # Glides 57 + for p in ("w", "y", "j", "ẏ"): 58 + _SONORITY[p] = 7 59 + # Vowels (and long vowels) 60 + for p in ("a", "e", "i", "o", "u", "ə", "ɨ", "ɯ", "ɛ", "ɔ", "ɑ", "æ", "ø", "y̆"): 61 + _SONORITY[p] = 8 62 + for p in ("aa", "ee", "ii", "oo", "uu", "aː", "eː", "iː", "oː", "uː"): 63 + _SONORITY[p] = 8 64 + for p in ("ai", "au", "ei", "oi", "ou", "ia", "ua", "ie", "ue"): 65 + _SONORITY[p] = 8 66 + 67 + 68 + def sonority_class(phoneme: str) -> int: 69 + """Return the sonority class for `phoneme`, or 4 if unknown.""" 70 + return _SONORITY.get(phoneme, 4) 71 + 72 + 73 + def passes_sonority(phonemes: list[str]) -> bool: 74 + """Soft sonority check on a phoneme sequence. 75 + 76 + Walks the sequence and rejects only the most flagrant violations: 77 + a phoneme cluster that crosses three sonority levels in the 78 + wrong direction at a syllable boundary. This catches things 79 + like /rtl/ but lets through plenty of marginal-but-plausible 80 + clusters that real languages allow. 81 + """ 82 + if len(phonemes) < 3: 83 + return True 84 + classes = [sonority_class(p) for p in phonemes] 85 + # Find vowel positions (the syllable nuclei) 86 + vowel_positions = [i for i, c in enumerate(classes) if c == 8] 87 + if not vowel_positions: 88 + return False 89 + # Onset (everything before first vowel) must rise weakly 90 + onset = classes[: vowel_positions[0]] 91 + if len(onset) >= 2 and onset[0] > onset[1]: 92 + # First consonant more sonorous than second is the canonical 93 + # sonority violation (e.g. /rt/ at word start) 94 + return False 95 + # Coda (everything after last vowel) should fall weakly 96 + coda = classes[vowel_positions[-1] + 1 :] 97 + return not (len(coda) >= 2 and coda[0] < coda[1])
+114
src/storied/names/engine/syllable.py
··· 1 + """Syllable template parser and sampler. 2 + 3 + The DSL is the de-facto template-pattern grammar from the conlang 4 + community, popularized by Awkwords (Petr Mejzlík) and Lexifer 5 + (William Annis). This is an original parser, not a port — see 6 + README.md for full attribution. 7 + 8 + Templates use the conlang community's `(C)V(C)` shorthand: 9 + 10 + C consonant (required, drawn from inventory.consonants) 11 + V vowel (drawn from inventory.vowels) 12 + L liquid (drawn from inventory.liquids, e.g. /l/, /r/) 13 + N nasal (drawn from inventory.nasals) 14 + F fricative (drawn from inventory.fricatives) 15 + () optional (50% chance of being filled, weighted higher 16 + toward the syllable nucleus) 17 + 18 + A template like `(C)(L)V(C)` parses into a list of "slots", each 19 + slot a (class-letter, optional?) tuple. Sampling walks the slots 20 + and produces one syllable. 21 + """ 22 + 23 + from __future__ import annotations 24 + 25 + import random 26 + from dataclasses import dataclass 27 + 28 + from storied.names.engine.inventory import PhonemeInventory 29 + 30 + 31 + @dataclass 32 + class Slot: 33 + """A single template slot: a phoneme class and whether it's optional.""" 34 + 35 + klass: str 36 + optional: bool 37 + 38 + 39 + def parse_template(template: str) -> list[Slot]: 40 + """Parse a `(C)V(C)` style template into ordered slots. 41 + 42 + Whitespace is ignored. Anything inside parens is one optional 43 + slot of one class letter. Anything outside parens is a required 44 + slot. Multi-letter slot codes (e.g. `(LV)`) are not supported — 45 + keep templates one-letter-per-slot. 46 + """ 47 + slots: list[Slot] = [] 48 + i = 0 49 + template = template.replace(" ", "") 50 + while i < len(template): 51 + ch = template[i] 52 + if ch == "(": 53 + end = template.find(")", i) 54 + if end == -1: 55 + raise ValueError(f"Unclosed paren in template '{template}'") 56 + inner = template[i + 1 : end] 57 + if len(inner) != 1: 58 + raise ValueError( 59 + f"Optional groups must be single class letters: '({inner})'" 60 + ) 61 + slots.append(Slot(klass=inner, optional=True)) 62 + i = end + 1 63 + elif ch in "CVLNF": 64 + slots.append(Slot(klass=ch, optional=False)) 65 + i += 1 66 + else: 67 + raise ValueError(f"Unknown class letter '{ch}' in template '{template}'") 68 + return slots 69 + 70 + 71 + def sample_syllable( 72 + slots: list[Slot], 73 + inventory: PhonemeInventory, 74 + rng: random.Random, 75 + optional_fill_prob: float = 0.55, 76 + ) -> list[str]: 77 + """Sample one syllable from the given slot pattern. 78 + 79 + Returns the phoneme list (not joined) so callers can run 80 + sonority and cluster checks before joining. 81 + 82 + Optional slots fill with probability `optional_fill_prob` — 83 + slightly above 50% so words don't degenerate into bare CV. 84 + """ 85 + pieces: list[str] = [] 86 + for slot in slots: 87 + if slot.optional and rng.random() > optional_fill_prob: 88 + continue 89 + try: 90 + phoneme = inventory.sample(slot.klass, rng) 91 + except ValueError: 92 + continue 93 + pieces.append(phoneme) 94 + return pieces 95 + 96 + 97 + def sample_word( 98 + templates: list[list[Slot]], 99 + inventory: PhonemeInventory, 100 + rng: random.Random, 101 + syllable_count: tuple[int, int] = (1, 3), 102 + ) -> list[str]: 103 + """Sample a word as a flat sequence of phonemes. 104 + 105 + `syllable_count` is a (min, max) inclusive range. Each syllable 106 + is drawn from a randomly-chosen template. Returns the flat 107 + phoneme list across all syllables. 108 + """ 109 + n = rng.randint(*syllable_count) 110 + phonemes: list[str] = [] 111 + for _ in range(n): 112 + slots = rng.choice(templates) 113 + phonemes.extend(sample_syllable(slots, inventory, rng)) 114 + return phonemes
+413
src/storied/names/forge.py
··· 1 + """CultureForge: procedural per-campaign culture generation. 2 + 3 + The forge is the layer that turns "I want a coastal trading culture" 4 + into a concrete, saved, ready-to-use culture profile. It does the 5 + random work — picking an inventory, trimming it, choosing templates 6 + and morphology, picking a few forbidden clusters, picking rewrite 7 + rules — and then runs the assembled engine to generate the culture's 8 + own self-name as its first emission. 9 + 10 + The layered architecture (inventory → templates → constraints → 11 + rewrites → assembled engine) follows William Annis's Lexifer. The 12 + forge layer on top of that — randomizing inputs per culture and 13 + generating self-names from the assembled engine — is original to 14 + this module. See README.md for full attribution. 15 + 16 + Critically: morphology suffixes and place-name suffixes are 17 + **generated by the culture's own engine**, not pulled from a fixed 18 + bank. This means a Polynesian-flavored culture has Polynesian-shaped 19 + suffixes, not Latin "-us"; a Nordic-flavored culture has its own 20 + short Norse-feeling suffixes. The whole culture is internally 21 + phonotactically consistent. 22 + 23 + The self-name becomes the file id and the canonical reference. Two 24 + cultures forged in the same campaign with the same self-name would 25 + collide; the forge re-rolls in that case. 26 + 27 + Storage is filesystem-only: each forged culture is one YAML file in 28 + `{world_path}/cultures/{self-name}.yaml`. The storied tool adapter 29 + also creates a markdown stub entity at `{self-name}.md` so the 30 + storied search index can find it, but that's outside this module's 31 + concern. 32 + """ 33 + 34 + from __future__ import annotations 35 + 36 + import random 37 + import re 38 + import unicodedata 39 + from dataclasses import dataclass, field 40 + from pathlib import Path 41 + from typing import Any 42 + 43 + import yaml 44 + 45 + from storied.names.data.inventories import ( 46 + all_inventories, 47 + inventories_for_feel, 48 + ) 49 + from storied.names.data.templates import TEMPLATE_SETS 50 + from storied.names.engine.clusters import FORBIDDEN_BANK 51 + from storied.names.engine.generator import Engine 52 + from storied.names.engine.inventory import PhonemeInventory 53 + 54 + 55 + @dataclass 56 + class Morphology: 57 + """Gender morphology generated for one culture. 58 + 59 + `applies` is False for cultures that don't mark gender on names. 60 + Suffixes are produced by the forge using the culture's own engine, 61 + so they're phonotactically consistent with the rest of the names. 62 + """ 63 + 64 + applies: bool 65 + female_suffixes: list[str] 66 + male_suffixes: list[str] 67 + neutral_suffixes: list[str] 68 + 69 + 70 + @dataclass 71 + class ForgedCulture: 72 + """A complete forged culture: phonotactic profile + metadata. 73 + 74 + This is the in-memory representation. Persisted as YAML by the 75 + forge to `{world_path}/cultures/{name}.yaml`. 76 + """ 77 + 78 + name: str 79 + feel: str | None 80 + forge_seed: int 81 + source_inventory: str 82 + inventory: PhonemeInventory 83 + templates: list[str] 84 + forbidden_clusters: list[str] 85 + rewrite_rules: list[str] 86 + morphology: Morphology 87 + place_suffixes: list[str] 88 + 89 + def to_dict(self) -> dict[str, Any]: 90 + """Serialize to a YAML-friendly dict.""" 91 + return { 92 + "name": self.name, 93 + "feel": self.feel, 94 + "forge_seed": self.forge_seed, 95 + "source_inventory": self.source_inventory, 96 + "inventory": { 97 + "consonants": self.inventory.consonants, 98 + "vowels": self.inventory.vowels, 99 + "liquids": self.inventory.liquids, 100 + "nasals": self.inventory.nasals, 101 + "fricatives": self.inventory.fricatives, 102 + }, 103 + "templates": self.templates, 104 + "forbidden_clusters": self.forbidden_clusters, 105 + "rewrite_rules": self.rewrite_rules, 106 + "morphology": { 107 + "applies": self.morphology.applies, 108 + "female_suffixes": self.morphology.female_suffixes, 109 + "male_suffixes": self.morphology.male_suffixes, 110 + "neutral_suffixes": self.morphology.neutral_suffixes, 111 + }, 112 + "place_suffixes": self.place_suffixes, 113 + } 114 + 115 + @classmethod 116 + def from_dict(cls, data: dict[str, Any]) -> ForgedCulture: 117 + """Hydrate from a parsed YAML dict.""" 118 + inv_data = data["inventory"] 119 + morph_data = data["morphology"] 120 + return cls( 121 + name=data["name"], 122 + feel=data.get("feel"), 123 + forge_seed=data["forge_seed"], 124 + source_inventory=data["source_inventory"], 125 + inventory=PhonemeInventory( 126 + name=data["source_inventory"], 127 + consonants=inv_data["consonants"], 128 + vowels=inv_data["vowels"], 129 + liquids=inv_data.get("liquids", []), 130 + nasals=inv_data.get("nasals", []), 131 + fricatives=inv_data.get("fricatives", []), 132 + ), 133 + templates=data["templates"], 134 + forbidden_clusters=data.get("forbidden_clusters", []), 135 + rewrite_rules=data.get("rewrite_rules", []), 136 + morphology=Morphology( 137 + applies=morph_data["applies"], 138 + female_suffixes=morph_data["female_suffixes"], 139 + male_suffixes=morph_data["male_suffixes"], 140 + neutral_suffixes=morph_data["neutral_suffixes"], 141 + ), 142 + place_suffixes=data.get("place_suffixes", []), 143 + ) 144 + 145 + def engine(self) -> Engine: 146 + """Build a runnable Engine from this culture's profile.""" 147 + return Engine( 148 + inventory=self.inventory, 149 + templates=self.templates, 150 + forbidden_clusters=self.forbidden_clusters, 151 + rewrite_rules=self.rewrite_rules, 152 + ) 153 + 154 + 155 + @dataclass 156 + class CultureForge: 157 + """Procedural culture forge for one world. 158 + 159 + `world_path` is the directory containing this campaign's 160 + `cultures/` subdirectory. The forge writes YAML files there 161 + and reads existing forges to detect collisions. 162 + """ 163 + 164 + world_path: Path 165 + cultures_subdir: str = "cultures" 166 + _existing: set[str] = field(default_factory=set) 167 + 168 + def __post_init__(self) -> None: 169 + self._refresh_existing() 170 + 171 + def _refresh_existing(self) -> None: 172 + """Re-scan the cultures dir for already-forged self-names.""" 173 + cultures_dir = self.world_path / self.cultures_subdir 174 + if not cultures_dir.exists(): 175 + self._existing = set() 176 + return 177 + self._existing = {p.stem for p in cultures_dir.glob("*.yaml")} 178 + 179 + def forge( 180 + self, 181 + feel: str | None = None, 182 + seed: int | None = None, 183 + ) -> ForgedCulture: 184 + """Forge a new culture for this campaign. 185 + 186 + If `seed` is None, draws a fresh random seed. If the resulting 187 + self-name collides with an existing culture in this campaign, 188 + re-rolls with a new seed until unique. 189 + """ 190 + used_seed = seed if seed is not None else random.randrange(2**31) 191 + for _ in range(50): 192 + culture = self._forge_one(feel=feel, seed=used_seed) 193 + if culture.name and culture.name not in self._existing: 194 + self._save(culture) 195 + self._existing.add(culture.name) 196 + return culture 197 + used_seed = random.randrange(2**31) 198 + # Fallback: append a numeric suffix to make it unique 199 + suffix = 2 200 + base_name = culture.name or "culture" 201 + while f"{base_name}{suffix}" in self._existing: 202 + suffix += 1 203 + culture.name = f"{base_name}{suffix}" 204 + self._save(culture) 205 + self._existing.add(culture.name) 206 + return culture 207 + 208 + def _forge_one( 209 + self, 210 + feel: str | None, 211 + seed: int, 212 + ) -> ForgedCulture: 213 + """Build one culture (without uniqueness checking).""" 214 + rng = random.Random(seed) 215 + 216 + # Pick an inventory (biased by feel if given) 217 + candidates = ( 218 + inventories_for_feel(feel) if feel else all_inventories() 219 + ) 220 + source = rng.choice(candidates) 221 + 222 + # Trim it for distinctiveness, but only if it's big enough 223 + if len(source.consonants) > 12: 224 + inventory = source.trim(rng, drop_fraction=0.15) 225 + else: 226 + inventory = PhonemeInventory( 227 + name=source.name, 228 + consonants=list(source.consonants), 229 + vowels=list(source.vowels), 230 + liquids=list(source.liquids), 231 + nasals=list(source.nasals), 232 + fricatives=list(source.fricatives), 233 + ) 234 + 235 + # Pick a template set 236 + templates = list(rng.choice(TEMPLATE_SETS)) 237 + 238 + # Pick 3-5 forbidden clusters from the bank 239 + n_forbidden = rng.randint(3, 5) 240 + forbidden = rng.sample( 241 + FORBIDDEN_BANK, k=min(n_forbidden, len(FORBIDDEN_BANK)), 242 + ) 243 + 244 + # Pick 0-2 rewrite rules 245 + rewrite_rules = self._pick_rewrite_rules(rng) 246 + 247 + # Build the engine. The same engine is used to generate the 248 + # self-name AND the morphology suffixes AND the place suffixes, 249 + # which is what makes everything internally consistent. 250 + engine = Engine( 251 + inventory=inventory, 252 + templates=templates, 253 + forbidden_clusters=forbidden, 254 + rewrite_rules=rewrite_rules, 255 + syllable_count=(2, 3), 256 + min_length=4, 257 + max_length=10, 258 + ) 259 + 260 + # The first emission of the engine is the culture's name 261 + self_name_seed = (seed * 31 + 7) & 0x7FFFFFFF 262 + self_name = self._generate_self_name(engine, self_name_seed) 263 + 264 + # Generate morphology suffixes from the engine itself 265 + morphology = self._generate_morphology(engine, rng) 266 + 267 + # Generate place suffixes from the engine itself 268 + place_suffixes = self._generate_place_suffixes(engine, rng) 269 + 270 + return ForgedCulture( 271 + name=self_name, 272 + feel=feel, 273 + forge_seed=seed, 274 + source_inventory=source.name, 275 + inventory=inventory, 276 + templates=templates, 277 + forbidden_clusters=forbidden, 278 + rewrite_rules=rewrite_rules, 279 + morphology=morphology, 280 + place_suffixes=place_suffixes, 281 + ) 282 + 283 + def _generate_self_name(self, engine: Engine, seed: int) -> str: 284 + """Generate the culture's own self-name from the engine. 285 + 286 + Constrains to 4-9 chars, ASCII-friendly. The self-name is 287 + the cultural signature and the file id, so it has to be 288 + readable. 289 + """ 290 + sub_engine = Engine( 291 + inventory=engine.inventory, 292 + templates=engine.templates, 293 + forbidden_clusters=engine.forbidden_clusters, 294 + rewrite_rules=engine.rewrite_rules, 295 + syllable_count=(2, 3), 296 + min_length=4, 297 + max_length=9, 298 + ) 299 + for offset in range(20): 300 + candidate = sub_engine.name(seed=seed + offset) 301 + ascii_form = _ascii_slug(candidate) 302 + if 4 <= len(ascii_form) <= 9: 303 + return ascii_form 304 + return _ascii_slug(sub_engine.name(seed=seed)) 305 + 306 + def _generate_morphology( 307 + self, engine: Engine, rng: random.Random, 308 + ) -> Morphology: 309 + """Generate gendered suffixes from the engine, or none.""" 310 + if rng.random() < 0.4: 311 + return Morphology( 312 + applies=False, 313 + female_suffixes=[], 314 + male_suffixes=[], 315 + neutral_suffixes=[], 316 + ) 317 + return Morphology( 318 + applies=True, 319 + female_suffixes=self._suffixes(engine, rng, count=5), 320 + male_suffixes=self._suffixes(engine, rng, count=5), 321 + neutral_suffixes=self._suffixes(engine, rng, count=4), 322 + ) 323 + 324 + def _suffixes( 325 + self, engine: Engine, rng: random.Random, count: int, 326 + ) -> list[str]: 327 + """Generate `count` short suffixes from the engine. 328 + 329 + Suffixes are 1-3 character word fragments that the 330 + morphology layer attaches to roots. We use a stripped-down 331 + engine with shorter syllable counts to get short forms. 332 + """ 333 + sub = Engine( 334 + inventory=engine.inventory, 335 + templates=engine.templates, 336 + forbidden_clusters=engine.forbidden_clusters, 337 + rewrite_rules=engine.rewrite_rules, 338 + syllable_count=(1, 1), 339 + min_length=1, 340 + max_length=4, 341 + ) 342 + out: set[str] = set() 343 + attempts = 0 344 + while len(out) < count and attempts < count * 20: 345 + attempts += 1 346 + candidate = _ascii_slug(sub.name(seed=rng.randrange(2**31))).lower() 347 + if 1 <= len(candidate) <= 4: 348 + out.add(candidate) 349 + return sorted(out) 350 + 351 + def _generate_place_suffixes( 352 + self, engine: Engine, rng: random.Random, 353 + ) -> list[str]: 354 + """Generate place-name suffixes from the engine.""" 355 + return self._suffixes(engine, rng, count=5) 356 + 357 + def _pick_rewrite_rules(self, rng: random.Random) -> list[str]: 358 + """Pick a small set of orthographic rewrite rules. 359 + 360 + These normalize IPA-style characters to ASCII-friendly 361 + spellings, plus a few cosmetic substitutions. 362 + """ 363 + bank = [ 364 + "ʃ -> sh", 365 + "ŋ -> ng", 366 + "θ -> th", 367 + "ð -> dh", 368 + "ç -> ch", 369 + "ɲ -> ny", 370 + "kw -> qu", 371 + "ks -> x", 372 + "rr -> r", 373 + "ll$ -> l", 374 + ] 375 + n = rng.randint(1, 3) 376 + return rng.sample(bank, k=min(n, len(bank))) 377 + 378 + def _save(self, culture: ForgedCulture) -> Path: 379 + """Write the culture YAML to disk and return the path.""" 380 + cultures_dir = self.world_path / self.cultures_subdir 381 + cultures_dir.mkdir(parents=True, exist_ok=True) 382 + path = cultures_dir / f"{culture.name}.yaml" 383 + path.write_text(yaml.safe_dump(culture.to_dict(), sort_keys=False)) 384 + return path 385 + 386 + def load(self, name: str) -> ForgedCulture | None: 387 + """Load a previously-forged culture by self-name.""" 388 + path = self.world_path / self.cultures_subdir / f"{name}.yaml" 389 + if not path.exists(): 390 + return None 391 + data = yaml.safe_load(path.read_text()) 392 + return ForgedCulture.from_dict(data) 393 + 394 + def list_names(self) -> list[str]: 395 + """Return the self-names of every culture forged for this world.""" 396 + cultures_dir = self.world_path / self.cultures_subdir 397 + if not cultures_dir.exists(): 398 + return [] 399 + return sorted(p.stem for p in cultures_dir.glob("*.yaml")) 400 + 401 + 402 + def _ascii_slug(word: str) -> str: 403 + """ASCII-fold a word for use as a filename id. 404 + 405 + Strips accents (Ä → A, ñ → n) and removes any remaining 406 + non-alphabetic characters. Lowercased. 407 + """ 408 + if not word: 409 + return "" 410 + decomposed = unicodedata.normalize("NFKD", word) 411 + ascii_chars = "".join(c for c in decomposed if not unicodedata.combining(c)) 412 + ascii_chars = re.sub(r"[^A-Za-z]", "", ascii_chars) 413 + return ascii_chars.lower()
+135
src/storied/names/generator.py
··· 1 + """Generator: high-level API for producing names from forged cultures. 2 + 3 + The Generator loads ForgedCulture YAMLs from a world directory and 4 + produces names on demand. It's the surface the storied tool adapter 5 + calls; tests can also use it directly. 6 + 7 + Two methods: 8 + sample(culture, n, gender, rarity, kind="person") 9 + Generate `n` distinct person names from the named culture, 10 + with optional gender and rarity tuning. 11 + place(culture, category, n) 12 + Generate `n` place names from the culture's place suffix 13 + bank attached to fresh root names. 14 + """ 15 + 16 + from __future__ import annotations 17 + 18 + import random 19 + from dataclasses import dataclass 20 + from pathlib import Path 21 + 22 + from storied.names.engine.rewrite import capitalize_name 23 + from storied.names.forge import CultureForge, ForgedCulture 24 + from storied.names.morphology import apply_morphology 25 + 26 + 27 + @dataclass 28 + class Generator: 29 + """Loads forged cultures from a world directory and produces names.""" 30 + 31 + world_path: Path 32 + cultures_subdir: str = "cultures" 33 + 34 + def _forge(self) -> CultureForge: 35 + return CultureForge( 36 + world_path=self.world_path, cultures_subdir=self.cultures_subdir, 37 + ) 38 + 39 + def load_culture(self, name: str) -> ForgedCulture | None: 40 + """Load a culture by self-name.""" 41 + return self._forge().load(name) 42 + 43 + def list_cultures(self) -> list[ForgedCulture]: 44 + """Return every forged culture in this world.""" 45 + forge = self._forge() 46 + return [c for name in forge.list_names() if (c := forge.load(name))] 47 + 48 + def sample( 49 + self, 50 + culture: str, 51 + count: int = 5, 52 + gender: str | None = None, 53 + rarity: str = "common", 54 + kind: str = "person", 55 + seed: int | None = None, 56 + ) -> list[str]: 57 + """Generate `count` distinct names from a forged culture. 58 + 59 + `gender`: "female", "male", "neutral", or None 60 + `rarity`: "common", "uncommon", "rare", "archaic" 61 + `kind`: "person" or "place" 62 + """ 63 + loaded = self.load_culture(culture) 64 + if loaded is None: 65 + return [] 66 + if kind == "place": 67 + return self._place_names(loaded, count, seed) 68 + return self._person_names(loaded, count, gender, rarity, seed) 69 + 70 + def _person_names( 71 + self, 72 + culture: ForgedCulture, 73 + count: int, 74 + gender: str | None, 75 + rarity: str, 76 + seed: int | None, 77 + ) -> list[str]: 78 + rng = random.Random(seed) 79 + engine = culture.engine() 80 + 81 + # Rarity tuning: rare/archaic names use longer syllable counts 82 + # which biases away from the common Zipfian peak. We also 83 + # nudge the length envelope so longer outputs are allowed. 84 + if rarity == "rare": 85 + engine.syllable_count = (2, 3) 86 + engine.max_length = 11 87 + elif rarity == "archaic": 88 + engine.syllable_count = (3, 4) 89 + engine.max_length = 13 90 + elif rarity == "uncommon": 91 + engine.syllable_count = (2, 3) 92 + engine.max_length = 10 93 + else: # common 94 + engine.syllable_count = (2, 2) 95 + engine.max_length = 8 96 + 97 + seen: set[str] = set() 98 + out: list[str] = [] 99 + attempts = 0 100 + max_attempts = count * 30 101 + while len(out) < count and attempts < max_attempts: 102 + root = engine.name(seed=rng.randint(0, 2**31 - 1)) 103 + full = apply_morphology(root, culture.morphology, gender, rng) 104 + full = capitalize_name(full) 105 + if full and full not in seen: 106 + seen.add(full) 107 + out.append(full) 108 + attempts += 1 109 + return out 110 + 111 + def _place_names( 112 + self, 113 + culture: ForgedCulture, 114 + count: int, 115 + seed: int | None, 116 + ) -> list[str]: 117 + rng = random.Random(seed) 118 + engine = culture.engine() 119 + suffixes = culture.place_suffixes or [""] 120 + seen: set[str] = set() 121 + out: list[str] = [] 122 + attempts = 0 123 + max_attempts = count * 30 124 + while len(out) < count and attempts < max_attempts: 125 + root = engine.name(seed=rng.randint(0, 2**31 - 1)) 126 + suffix = rng.choice(suffixes) 127 + if suffix: 128 + place = capitalize_name(root.lower() + suffix) 129 + else: 130 + place = capitalize_name(root) 131 + if place and place not in seen: 132 + seen.add(place) 133 + out.append(place) 134 + attempts += 1 135 + return out
+69
src/storied/names/morphology.py
··· 1 + """Gender morphology: applying gendered suffixes to root names. 2 + 3 + The forge produces a phonotactic engine that generates gender-neutral 4 + roots. The morphology layer attaches a gendered suffix on top of the 5 + root for cultures where gender is morphologically marked. For 6 + cultures with `morphology.applies = False`, the gender parameter is 7 + ignored and the root is returned as-is. 8 + """ 9 + 10 + from __future__ import annotations 11 + 12 + import random 13 + from typing import TYPE_CHECKING 14 + 15 + if TYPE_CHECKING: 16 + from storied.names.forge import Morphology 17 + 18 + 19 + def apply_morphology( 20 + root: str, 21 + morphology: Morphology, 22 + gender: str | None, 23 + rng: random.Random, 24 + bare_probability: float = 0.4, 25 + ) -> str: 26 + """Attach a gendered suffix to a root name. 27 + 28 + `gender` is one of "female", "male", "neutral", or None. None 29 + is treated as neutral. If the morphology profile doesn't apply 30 + to this culture, the root is returned unchanged. 31 + 32 + Even when morphology applies, names go bare with 33 + `bare_probability` (default 40%) — real names aren't all 34 + suffixed, and skipping some keeps the output from feeling 35 + rubber-stamped. 36 + """ 37 + if not morphology.applies: 38 + return root 39 + 40 + if rng.random() < bare_probability: 41 + return root 42 + 43 + if gender == "female" and morphology.female_suffixes: 44 + suffix = rng.choice(morphology.female_suffixes) 45 + elif gender == "male" and morphology.male_suffixes: 46 + suffix = rng.choice(morphology.male_suffixes) 47 + elif morphology.neutral_suffixes: 48 + suffix = rng.choice(morphology.neutral_suffixes) 49 + else: 50 + return root 51 + 52 + if not suffix: 53 + return root 54 + return _join_with_suffix(root, suffix) 55 + 56 + 57 + def _join_with_suffix(root: str, suffix: str) -> str: 58 + """Glue a suffix onto a root, avoiding duplicate adjacent letters. 59 + 60 + If the root ends in the same letter the suffix starts with, 61 + drop one of them. This is a small heuristic to avoid the 62 + kind of "Maraaa" ugliness that plain string concatenation 63 + produces. 64 + """ 65 + if not suffix: 66 + return root 67 + if root and suffix[0].lower() == root[-1].lower(): 68 + return root + suffix[1:] 69 + return root + suffix
+1 -1
src/storied/planner.py
··· 603 603 return [] 604 604 605 605 results: list[tuple[str, Path]] = [] 606 - for etype in ("npcs", "locations", "items", "factions", "threads"): 606 + for etype in ("npcs", "locations", "items", "factions", "threads", "cultures"): 607 607 type_dir = world_dir / etype 608 608 if not type_dir.exists(): 609 609 continue
+3 -2
src/storied/session.py
··· 1 1 """Session state management for persistent game state.""" 2 2 3 3 import re 4 - from datetime import datetime, timezone 4 + from datetime import UTC, datetime 5 5 from pathlib import Path 6 6 7 7 import yaml ··· 57 57 session_path.parent.mkdir(parents=True, exist_ok=True) 58 58 59 59 # Update timestamp 60 - data["updated"] = datetime.now(timezone.utc).isoformat() 60 + data["updated"] = datetime.now(UTC).isoformat() 61 61 62 62 # Separate body from frontmatter 63 63 body = data.pop("body", "") ··· 164 164 # Priority order for wikilink resolution 165 165 ENTITY_TYPES = [ 166 166 "npcs", "locations", "items", "factions", "threads", "lore", "maps", 167 + "cultures", 167 168 ] 168 169 169 170
+15 -5
src/storied/tools/entities.py
··· 26 26 # slightly different — only the kinds that make sense for that operation. 27 27 EstablishType = Literal[ 28 28 "npcs", "locations", "items", "factions", "threads", "lore", "maps", 29 + "cultures", 30 + ] 31 + MarkType = Literal[ 32 + "npcs", "locations", "items", "factions", "threads", "maps", "cultures", 29 33 ] 30 - MarkType = Literal["npcs", "locations", "items", "factions", "threads", "maps"] 31 - DiscoveryType = Literal["npcs", "locations", "factions", "lore"] 34 + DiscoveryType = Literal["npcs", "locations", "factions", "lore", "cultures"] 32 35 33 36 mcp = FastMCP("entities") 34 37 ··· 63 66 desc = re.sub(r"\*\*Location:\*\*\s*.+\n?", "", desc).strip() 64 67 result["description"] = desc 65 68 66 - knows_match = re.search(r"### Knows\n\n?(.*?)(?=\n### |\n## |\Z)", is_content, re.DOTALL) 69 + section_re = r"### {label}\n\n?(.*?)(?=\n### |\n## |\Z)" 70 + knows_match = re.search( 71 + section_re.format(label="Knows"), is_content, re.DOTALL, 72 + ) 67 73 if knows_match: 68 74 result["knows"] = _parse_list_items(knows_match.group(1)) 69 75 70 - wants_match = re.search(r"### Wants\n\n?(.*?)(?=\n### |\n## |\Z)", is_content, re.DOTALL) 76 + wants_match = re.search( 77 + section_re.format(label="Wants"), is_content, re.DOTALL, 78 + ) 71 79 if wants_match: 72 80 result["wants"] = _parse_list_items(wants_match.group(1)) 73 81 74 - will_match = re.search(r"### Will\n\n?(.*?)(?=\n### |\n## |\Z)", is_content, re.DOTALL) 82 + will_match = re.search( 83 + section_re.format(label="Will"), is_content, re.DOTALL, 84 + ) 75 85 if will_match: 76 86 result["will"] = _parse_list_items(will_match.group(1)) 77 87
+155
src/storied/tools/names.py
··· 1 + """MCP tool adapter for storied.names — fantasy name generation. 2 + 3 + This is the ONLY file allowed to import from `storied.names`. It 4 + bridges the self-contained name generator into storied's MCP tool 5 + surface and the world filesystem layout. 6 + 7 + Two tools: 8 + 9 + forge_culture(feel) 10 + Procedurally generate a fresh culture for this campaign. 11 + Picks an inventory, syllable templates, morphology, and 12 + runs the assembled engine to produce the culture's own 13 + self-name. Saves the phonotactic profile as YAML and 14 + creates a stub culture entity so the storied search index 15 + finds it. 16 + 17 + generate_names(culture, count, gender, rarity, kind) 18 + Generate names from a previously-forged culture. 19 + 20 + Discovery is handled by the existing `recall` tool — cultures 21 + appear there as `entity_type=cultures` once forged. 22 + """ 23 + 24 + from __future__ import annotations 25 + 26 + from fastmcp import FastMCP 27 + 28 + from storied.names import CultureForge, Generator 29 + from storied.paths import world_path 30 + from storied.search import VectorIndex 31 + from storied.tools._context import ( 32 + Entities, 33 + EntityIndex, 34 + Lore, 35 + World, 36 + ) 37 + from storied.tools.entities import _do_establish 38 + 39 + mcp = FastMCP("names") 40 + 41 + 42 + @mcp.tool(tags={"seeder", "dm", "planner"}) 43 + def forge_culture( 44 + feel: str | None = None, 45 + world: str = World(), 46 + entities: EntityIndex = Entities(), 47 + lore: VectorIndex = Lore(), 48 + ) -> str: 49 + """Forge a fresh culture for this campaign. 50 + 51 + A culture is a phonotactic profile (phoneme inventory, syllable 52 + templates, morphology, orthographic rewrites) plus an in-fiction 53 + self-name the engine generates as its first emission. The 54 + self-name becomes the culture id and the entity name; later 55 + `generate_names` calls reference the culture by that name. 56 + 57 + The forge picks all of its inputs randomly, optionally biased 58 + by an archetype `feel`: 59 + 60 + - "coastal", "highland", "highborn", "industrial", "pastoral" 61 + - "ancient", "forest", "desert", "steppe", "liturgical" 62 + 63 + The output is a culture entity at `cultures/{self-name}.md` 64 + with a phonotactic profile YAML alongside it. The seeder's job 65 + after forging is to flesh out the culture entity body with 66 + `establish` (description, Knows/Wants/Will, [[wikilinks]] to 67 + the regions and factions where the culture lives). 68 + 69 + Args: 70 + feel: Optional archetype hint that biases inventory selection. 71 + 72 + Returns: 73 + A line naming the new culture and listing a few sample names 74 + from it, so you can hear what it sounds like before deciding 75 + where it lives in the story. 76 + """ 77 + forge = CultureForge(world_path=world_path(world)) 78 + culture = forge.forge(feel=feel) 79 + 80 + # Sample a few names so the caller can hear what the culture sounds like 81 + gen = Generator(world_path=world_path(world)) 82 + samples = gen.sample(culture=culture.name, count=4, rarity="common") 83 + sample_str = ", ".join(samples) if samples else "(no samples)" 84 + 85 + # Create a stub entity so recall can find this culture. The seeder 86 + # will fill in the body with a real establish() call later. 87 + feel_text = f" ({culture.feel} feel)" if culture.feel else "" 88 + description = ( 89 + f"A freshly-forged culture awaiting placement in the world" 90 + f"{feel_text}. Sample names: {sample_str}. Use " 91 + f"`generate_names(culture=\"{culture.name}\", ...)` to draw more " 92 + f"names. Flesh out this entity with `establish` to give the " 93 + f"culture its place in the story." 94 + ) 95 + _do_establish( 96 + entity_type="cultures", 97 + name=culture.name, 98 + description=description, 99 + location=None, 100 + knows=None, 101 + wants=None, 102 + will=None, 103 + world_id=world, 104 + entity_index=entities, 105 + lore=lore, 106 + ) 107 + 108 + return ( 109 + f"Forged culture '{culture.name}' (source: {culture.source_inventory}" 110 + f"{feel_text}). Sample names: {sample_str}" 111 + ) 112 + 113 + 114 + @mcp.tool(tags={"dm", "seeder", "planner"}) 115 + def generate_names( 116 + culture: str, 117 + count: int = 5, 118 + gender: str | None = None, 119 + rarity: str = "common", 120 + kind: str = "person", 121 + world: str = World(), 122 + ) -> list[str]: 123 + """Generate names from one of this campaign's forged cultures. 124 + 125 + Use this for every named NPC and location instead of inventing 126 + names yourself. The forged culture supplies its own coherent 127 + phonotactics — names from the same culture sound related, names 128 + from different cultures sound distinct. 129 + 130 + Use `recall(scope="world", entity_type="cultures")` to discover 131 + which cultures this campaign has forged, or call `forge_culture` 132 + to forge a new one. 133 + 134 + Args: 135 + culture: The culture's self-name (e.g., "saerwood"). Use 136 + `recall` to find what's been forged. 137 + count: How many names to generate (default 5). 138 + gender: "female", "male", "neutral", or None. Cultures 139 + without gender morphology ignore this. 140 + rarity: "common" (short, frequent), "uncommon", "rare", or 141 + "archaic" (longer, more elaborate forms). 142 + kind: "person" or "place". 143 + 144 + Returns: 145 + A list of generated names. Empty if the culture is not 146 + found. 147 + """ 148 + gen = Generator(world_path=world_path(world)) 149 + return gen.sample( 150 + culture=culture, 151 + count=count, 152 + gender=gender, 153 + rarity=rarity, 154 + kind=kind, 155 + )
+8 -4
tests/test_mcp_server.py
··· 72 72 def test_planner_only_has_its_tools(self): 73 73 assert _names("planner") == { 74 74 "establish", "mark", "amend_mark", "notify_dm", "recall", 75 + "forge_culture", "generate_names", 75 76 } 76 77 77 78 def test_seeder_only_has_its_tools(self): 78 - assert _names("seeder") == {"establish", "set_scene"} 79 + assert _names("seeder") == { 80 + "establish", "set_scene", "forge_culture", "generate_names", 81 + } 79 82 80 83 def test_advancement_only_has_its_tools(self): 81 84 assert _names("advancement") == {"notify_dm", "recall", "update_character"} ··· 151 154 ("recall", "scope", {"rules", "world", "all"}), 152 155 ("establish", "entity_type", 153 156 {"npcs", "locations", "items", "factions", "threads", "lore", 154 - "maps"}), 157 + "maps", "cultures"}), 155 158 ("mark", "entity_type", 156 - {"npcs", "locations", "items", "factions", "threads", "maps"}), 159 + {"npcs", "locations", "items", "factions", "threads", "maps", 160 + "cultures"}), 157 161 ("note_discovery", "content_type", 158 - {"npcs", "locations", "factions", "lore"}), 162 + {"npcs", "locations", "factions", "lore", "cultures"}), 159 163 ], 160 164 ) 161 165 def test_enum_parameters_expose_valid_values(
+149
tests/test_names_discipline.py
··· 1 + """Import discipline test for storied.names. 2 + 3 + `storied.names` is designed to be lifted out of storied into a 4 + standalone Python package without modification. To make that 5 + extraction mechanical, nothing in `storied.names.*` may import 6 + from anywhere else in the `storied.*` namespace. The only file 7 + that bridges the two is `storied.tools.names`. 8 + 9 + This test AST-scans every module under `src/storied/names/` and 10 + fails the build if any of them have an import that would break 11 + the extraction. 12 + """ 13 + 14 + import ast 15 + from pathlib import Path 16 + 17 + import pytest 18 + 19 + 20 + def _names_module_root() -> Path: 21 + return Path(__file__).parent.parent / "src" / "storied" / "names" 22 + 23 + 24 + def _python_files_under(root: Path) -> list[Path]: 25 + return sorted(root.rglob("*.py")) 26 + 27 + 28 + class _ForbiddenImportVisitor(ast.NodeVisitor): 29 + """Walks an AST and collects any `storied.*` import that isn't 30 + inside `storied.names.*` itself.""" 31 + 32 + def __init__(self) -> None: 33 + self.violations: list[tuple[str, int]] = [] 34 + 35 + def _check_module(self, module: str | None, lineno: int) -> None: 36 + if not module: 37 + return 38 + if not module.startswith("storied"): 39 + return 40 + if module == "storied.names" or module.startswith("storied.names."): 41 + return 42 + self.violations.append((module, lineno)) 43 + 44 + def visit_Import(self, node: ast.Import) -> None: 45 + for alias in node.names: 46 + self._check_module(alias.name, node.lineno) 47 + self.generic_visit(node) 48 + 49 + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: 50 + # ImportFrom: `from X import Y, Z`. Relative imports 51 + # (`from . import X`) have node.module == None and node.level > 0. 52 + if node.level > 0: 53 + # Relative imports stay within the package — fine. 54 + self.generic_visit(node) 55 + return 56 + self._check_module(node.module, node.lineno) 57 + self.generic_visit(node) 58 + 59 + 60 + def _scan_file(path: Path) -> list[tuple[str, int]]: 61 + """Return list of (forbidden_module, lineno) violations for one file.""" 62 + tree = ast.parse(path.read_text()) 63 + visitor = _ForbiddenImportVisitor() 64 + visitor.visit(tree) 65 + return visitor.violations 66 + 67 + 68 + class TestImportDiscipline: 69 + def test_no_forbidden_storied_imports(self): 70 + """Every module under storied.names imports only from 71 + storied.names.* or non-storied packages.""" 72 + root = _names_module_root() 73 + all_violations: list[tuple[Path, str, int]] = [] 74 + for path in _python_files_under(root): 75 + for module, lineno in _scan_file(path): 76 + all_violations.append((path, module, lineno)) 77 + if all_violations: 78 + msg_lines = ["storied.names has forbidden cross-package imports:"] 79 + for path, module, lineno in all_violations: 80 + rel = path.relative_to(_names_module_root().parent.parent.parent) 81 + msg_lines.append(f" {rel}:{lineno} imports {module}") 82 + pytest.fail("\n".join(msg_lines)) 83 + 84 + def test_agents_md_exists(self): 85 + """The AGENTS.md is the contract that documents the discipline. 86 + It must be present alongside the module.""" 87 + agents = _names_module_root() / "AGENTS.md" 88 + assert agents.exists(), ( 89 + "src/storied/names/AGENTS.md is missing — it documents " 90 + "the import discipline that this module relies on." 91 + ) 92 + 93 + def test_module_root_has_python_files(self): 94 + # Sanity: if we accidentally point at an empty dir the 95 + # discipline test would pass vacuously. Make sure we're 96 + # actually scanning something. 97 + files = _python_files_under(_names_module_root()) 98 + assert len(files) > 5 99 + 100 + 101 + class TestForbiddenImportVisitor: 102 + """Direct tests for the AST visitor so coverage doesn't depend 103 + on the discipline scan ever finding a real violation.""" 104 + 105 + def _violations(self, source: str) -> list[tuple[str, int]]: 106 + tree = ast.parse(source) 107 + visitor = _ForbiddenImportVisitor() 108 + visitor.visit(tree) 109 + return visitor.violations 110 + 111 + def test_non_storied_import_passes(self): 112 + assert self._violations("import yaml") == [] 113 + 114 + def test_non_storied_from_import_passes(self): 115 + assert self._violations("from yaml import safe_load") == [] 116 + 117 + def test_storied_names_import_passes(self): 118 + assert self._violations("import storied.names.engine") == [] 119 + 120 + def test_storied_names_from_import_passes(self): 121 + assert self._violations( 122 + "from storied.names.engine import generator" 123 + ) == [] 124 + 125 + def test_relative_import_passes(self): 126 + assert self._violations("from . import forge") == [] 127 + 128 + def test_storied_paths_import_flagged(self): 129 + violations = self._violations("import storied.paths") 130 + assert len(violations) == 1 131 + assert violations[0][0] == "storied.paths" 132 + 133 + def test_storied_paths_from_import_flagged(self): 134 + violations = self._violations("from storied.paths import data_home") 135 + assert len(violations) == 1 136 + assert violations[0][0] == "storied.paths" 137 + 138 + def test_failure_message_construction(self, tmp_path: Path): 139 + # Force the failure path of the main discipline test by 140 + # building a fake names module with a violation, scanning 141 + # it, and asserting we get the violations the failure 142 + # message would report. 143 + fake = tmp_path / "fake_names" 144 + fake.mkdir() 145 + bad = fake / "bad.py" 146 + bad.write_text("from storied.paths import data_home\n") 147 + violations = _scan_file(bad) 148 + assert violations 149 + assert violations[0][0] == "storied.paths"
+293
tests/test_names_engine.py
··· 1 + """Tests for the storied.names phonotactic engine layer.""" 2 + 3 + import pytest 4 + 5 + from storied.names.engine.clusters import has_forbidden_cluster, normalize_word 6 + from storied.names.engine.generator import Engine 7 + from storied.names.engine.inventory import PhonemeInventory, zipfian_weights 8 + from storied.names.engine.rewrite import ( 9 + apply_rules, 10 + capitalize_name, 11 + parse_rule, 12 + ) 13 + from storied.names.engine.sonority import passes_sonority, sonority_class 14 + from storied.names.engine.syllable import ( 15 + parse_template, 16 + sample_syllable, 17 + sample_word, 18 + ) 19 + 20 + 21 + class TestZipfianWeights: 22 + def test_empty(self): 23 + assert zipfian_weights(0) == [] 24 + 25 + def test_single(self): 26 + weights = zipfian_weights(1) 27 + assert weights == [1.0] 28 + 29 + def test_normalized(self): 30 + weights = zipfian_weights(5) 31 + assert sum(weights) == pytest.approx(1.0) 32 + 33 + def test_decreasing(self): 34 + weights = zipfian_weights(6) 35 + for i in range(len(weights) - 1): 36 + assert weights[i] > weights[i + 1] 37 + 38 + 39 + class TestPhonemeInventory: 40 + @pytest.fixture 41 + def inventory(self) -> PhonemeInventory: 42 + return PhonemeInventory( 43 + name="test", 44 + consonants=["k", "t", "n", "m", "s", "r", "l"], 45 + vowels=["a", "e", "i", "o"], 46 + liquids=["r", "l"], 47 + nasals=["n", "m"], 48 + ) 49 + 50 + def test_classes_exposes_C_and_V(self, inventory: PhonemeInventory): 51 + classes = inventory.classes() 52 + assert classes["C"] == ["k", "t", "n", "m", "s", "r", "l"] 53 + assert classes["V"] == ["a", "e", "i", "o"] 54 + 55 + def test_classes_uses_explicit_liquids(self, inventory: PhonemeInventory): 56 + assert inventory.classes()["L"] == ["r", "l"] 57 + 58 + def test_classes_falls_back_to_heuristic_liquids(self): 59 + inv = PhonemeInventory( 60 + name="x", 61 + consonants=["t", "k", "l", "r", "m"], 62 + vowels=["a", "i"], 63 + ) 64 + # No explicit liquids — heuristic should pick l and r 65 + liquids = inv.classes()["L"] 66 + assert "l" in liquids 67 + assert "r" in liquids 68 + 69 + def test_sample_returns_phoneme(self, inventory: PhonemeInventory): 70 + import random 71 + rng = random.Random(42) 72 + result = inventory.sample("V", rng) 73 + assert result in inventory.vowels 74 + 75 + def test_sample_unknown_class_raises(self, inventory: PhonemeInventory): 76 + import random 77 + rng = random.Random(42) 78 + with pytest.raises(ValueError): 79 + inventory.sample("Z", rng) 80 + 81 + def test_trim_reduces_consonants(self, inventory: PhonemeInventory): 82 + import random 83 + rng = random.Random(42) 84 + trimmed = inventory.trim(rng, drop_fraction=0.3) 85 + # 0.3 * 7 ≈ 2 dropped from 7 86 + assert len(trimmed.consonants) < len(inventory.consonants) 87 + 88 + def test_trim_keeps_small_inventories(self): 89 + small = PhonemeInventory( 90 + name="tiny", consonants=["k", "n"], vowels=["a", "i"], 91 + ) 92 + import random 93 + rng = random.Random(42) 94 + trimmed = small.trim(rng, drop_fraction=0.5) 95 + assert trimmed.consonants == ["k", "n"] 96 + 97 + 98 + class TestSyllableParser: 99 + def test_simple_template(self): 100 + slots = parse_template("CV") 101 + assert len(slots) == 2 102 + assert slots[0].klass == "C" 103 + assert not slots[0].optional 104 + assert slots[1].klass == "V" 105 + 106 + def test_optional_slot(self): 107 + slots = parse_template("(C)V") 108 + assert slots[0].optional 109 + assert slots[1].klass == "V" 110 + assert not slots[1].optional 111 + 112 + def test_complex_template(self): 113 + slots = parse_template("(C)(L)V(C)") 114 + assert len(slots) == 4 115 + assert [s.klass for s in slots] == ["C", "L", "V", "C"] 116 + assert [s.optional for s in slots] == [True, True, False, True] 117 + 118 + def test_unclosed_paren_raises(self): 119 + with pytest.raises(ValueError, match="Unclosed paren"): 120 + parse_template("(CV") 121 + 122 + def test_unknown_class_raises(self): 123 + with pytest.raises(ValueError, match="Unknown class letter"): 124 + parse_template("CZV") 125 + 126 + def test_multi_letter_optional_raises(self): 127 + with pytest.raises(ValueError, match="single class letters"): 128 + parse_template("(CV)") 129 + 130 + 131 + class TestSampleSyllable: 132 + @pytest.fixture 133 + def inventory(self) -> PhonemeInventory: 134 + return PhonemeInventory( 135 + name="x", consonants=["k", "t"], vowels=["a", "i"], 136 + ) 137 + 138 + def test_returns_phoneme_list(self, inventory: PhonemeInventory): 139 + import random 140 + rng = random.Random(42) 141 + slots = parse_template("CV") 142 + result = sample_syllable(slots, inventory, rng) 143 + assert isinstance(result, list) 144 + assert len(result) == 2 145 + 146 + def test_optional_slot_sometimes_skipped( 147 + self, inventory: PhonemeInventory, 148 + ): 149 + import random 150 + rng = random.Random(42) 151 + slots = parse_template("(C)V") 152 + results = [ 153 + sample_syllable(slots, inventory, rng) for _ in range(50) 154 + ] 155 + # Some should be 1-element (skipped optional), some 2 156 + lengths = {len(r) for r in results} 157 + assert 1 in lengths or 2 in lengths 158 + 159 + 160 + class TestSampleWord: 161 + def test_produces_phonemes(self): 162 + import random 163 + rng = random.Random(42) 164 + inv = PhonemeInventory( 165 + name="x", consonants=["k", "t", "n"], vowels=["a", "i", "o"], 166 + ) 167 + slots = [parse_template("CV")] 168 + word = sample_word(slots, inv, rng, syllable_count=(2, 2)) 169 + assert len(word) == 4 # 2 syllables × 2 phonemes each 170 + 171 + 172 + class TestSonority: 173 + def test_vowel_class(self): 174 + assert sonority_class("a") == 8 175 + assert sonority_class("e") == 8 176 + 177 + def test_stop_class(self): 178 + assert sonority_class("p") == 1 179 + 180 + def test_unknown_defaults_to_4(self): 181 + assert sonority_class("zzqq") == 4 182 + 183 + def test_passes_short_word(self): 184 + assert passes_sonority(["k", "a"]) 185 + 186 + def test_passes_canonical_cv(self): 187 + assert passes_sonority(["k", "a", "t"]) 188 + 189 + def test_rejects_falling_onset(self): 190 + # /rk/ at word start: liquid (sonority 6) before stop (1) 191 + # That's a falling onset before the vowel, which the soft check catches 192 + assert not passes_sonority(["r", "k", "a"]) 193 + 194 + 195 + class TestClusters: 196 + def test_normalize_joins(self): 197 + assert normalize_word(["k", "a", "th"]) == "kath" 198 + 199 + def test_no_forbidden_passes(self): 200 + assert not has_forbidden_cluster("kath", []) 201 + 202 + def test_substring_match(self): 203 + assert has_forbidden_cluster("kathx", ["thx"]) 204 + 205 + def test_word_boundary_anchor_start(self): 206 + # #sr means /sr/ only at word start 207 + assert has_forbidden_cluster("srak", ["#sr"]) 208 + assert not has_forbidden_cluster("aksra", ["#sr"]) 209 + 210 + def test_word_boundary_anchor_end(self): 211 + assert has_forbidden_cluster("katl", ["tl#"]) 212 + assert not has_forbidden_cluster("katla", ["tl#"]) 213 + 214 + 215 + class TestRewrite: 216 + def test_parse_simple_rule(self): 217 + pat, repl = parse_rule("k -> g") 218 + assert pat.pattern == "k" 219 + assert repl == "g" 220 + 221 + def test_apply_simple_substitution(self): 222 + result = apply_rules("kakak", ["k -> g"]) 223 + assert result == "gagag" 224 + 225 + def test_apply_anchored_rule(self): 226 + result = apply_rules("hahah", ["h$ -> "]) 227 + assert result == "haha" 228 + 229 + def test_apply_multiple_rules_in_order(self): 230 + result = apply_rules("kath", ["th -> dh", "k -> g"]) 231 + assert result == "gadh" 232 + 233 + def test_invalid_rule_skipped(self): 234 + result = apply_rules("kath", ["no arrow", "k -> g"]) 235 + assert result == "gath" 236 + 237 + def test_capitalize_name(self): 238 + assert capitalize_name("aldric") == "Aldric" 239 + 240 + def test_capitalize_empty(self): 241 + assert capitalize_name("") == "" 242 + 243 + 244 + class TestEngine: 245 + @pytest.fixture 246 + def engine(self) -> Engine: 247 + return Engine( 248 + inventory=PhonemeInventory( 249 + name="test", 250 + consonants=["k", "t", "n", "m", "s", "l", "r"], 251 + vowels=["a", "e", "i", "o"], 252 + liquids=["l", "r"], 253 + nasals=["n", "m"], 254 + ), 255 + templates=["(C)V(C)", "CV"], 256 + forbidden_clusters=["#sr"], 257 + rewrite_rules=["k -> c"], 258 + min_length=3, 259 + max_length=10, 260 + ) 261 + 262 + def test_name_returns_string(self, engine: Engine): 263 + name = engine.name(seed=42) 264 + assert isinstance(name, str) 265 + assert len(name) >= 3 266 + 267 + def test_name_is_deterministic_for_seed(self, engine: Engine): 268 + a = engine.name(seed=42) 269 + b = engine.name(seed=42) 270 + assert a == b 271 + 272 + def test_name_is_capitalized(self, engine: Engine): 273 + name = engine.name(seed=42) 274 + assert name[0].isupper() 275 + 276 + def test_rewrite_rule_applied(self, engine: Engine): 277 + # Engine has "k -> c" rule, so no lowercase k should appear 278 + names = engine.names(count=20, seed=42) 279 + for name in names: 280 + assert "k" not in name 281 + 282 + def test_names_returns_distinct(self, engine: Engine): 283 + names = engine.names(count=10, seed=42) 284 + assert len(set(names)) == len(names) 285 + 286 + def test_names_respects_count(self, engine: Engine): 287 + names = engine.names(count=5, seed=42) 288 + assert len(names) == 5 289 + 290 + def test_names_within_length_envelope(self, engine: Engine): 291 + names = engine.names(count=20, seed=42) 292 + for name in names: 293 + assert engine.min_length <= len(name) <= engine.max_length
+162
tests/test_names_forge.py
··· 1 + """Tests for the CultureForge and high-level Generator.""" 2 + 3 + from pathlib import Path 4 + 5 + import pytest 6 + 7 + from storied.names.forge import CultureForge, ForgedCulture 8 + from storied.names.generator import Generator 9 + 10 + 11 + @pytest.fixture 12 + def world_dir(tmp_path: Path) -> Path: 13 + """A fresh world directory the forge can write to.""" 14 + return tmp_path / "test-world" 15 + 16 + 17 + class TestCultureForge: 18 + def test_forge_writes_yaml_file(self, world_dir: Path): 19 + forge = CultureForge(world_path=world_dir) 20 + culture = forge.forge(seed=42) 21 + path = world_dir / "cultures" / f"{culture.name}.yaml" 22 + assert path.exists() 23 + 24 + def test_forge_returns_self_named_culture(self, world_dir: Path): 25 + forge = CultureForge(world_path=world_dir) 26 + culture = forge.forge(seed=42) 27 + assert culture.name 28 + assert culture.name.isalpha() 29 + assert culture.name == culture.name.lower() 30 + 31 + def test_forge_self_name_in_length_window(self, world_dir: Path): 32 + forge = CultureForge(world_path=world_dir) 33 + for seed in range(10): 34 + culture = forge.forge(seed=seed * 100) 35 + assert 4 <= len(culture.name) <= 9, ( 36 + f"Culture name {culture.name!r} out of range" 37 + ) 38 + 39 + def test_forge_is_deterministic(self, tmp_path: Path): 40 + forge1 = CultureForge(world_path=tmp_path / "a") 41 + forge2 = CultureForge(world_path=tmp_path / "b") 42 + c1 = forge1.forge(seed=12345) 43 + c2 = forge2.forge(seed=12345) 44 + assert c1.name == c2.name 45 + 46 + def test_forge_different_seeds_yield_different_cultures( 47 + self, world_dir: Path, 48 + ): 49 + forge = CultureForge(world_path=world_dir) 50 + cultures = [forge.forge(seed=s) for s in range(10)] 51 + names = {c.name for c in cultures} 52 + assert len(names) >= 9 # tolerate one rare collision 53 + 54 + def test_forge_with_feel_picks_matching_inventory(self, world_dir: Path): 55 + # Forge several coastal cultures and check they came from 56 + # inventories tagged "coastal" in the data file. 57 + coastal_inventories = { 58 + "welsh", "old-norse", "polynesian", "austronesian", 59 + "iberian", "japonic", "arabic-port", "yoruboid", 60 + "hellenic", "cornish", 61 + } 62 + for seed in range(5): 63 + forge2 = CultureForge(world_path=world_dir.parent / f"w{seed}") 64 + culture = forge2.forge(feel="coastal", seed=seed) 65 + assert culture.source_inventory in coastal_inventories 66 + assert culture.feel == "coastal" 67 + 68 + def test_forge_collision_handling(self, world_dir: Path): 69 + forge = CultureForge(world_path=world_dir) 70 + c1 = forge.forge(seed=99) 71 + # Forge again with the same seed — must produce a unique name 72 + c2 = forge.forge(seed=99) 73 + assert c1.name != c2.name 74 + 75 + def test_load_round_trips(self, world_dir: Path): 76 + forge = CultureForge(world_path=world_dir) 77 + original = forge.forge(seed=42) 78 + loaded = forge.load(original.name) 79 + assert loaded is not None 80 + assert loaded.name == original.name 81 + assert loaded.source_inventory == original.source_inventory 82 + assert loaded.templates == original.templates 83 + assert loaded.forbidden_clusters == original.forbidden_clusters 84 + assert loaded.morphology.applies == original.morphology.applies 85 + 86 + def test_load_missing_returns_none(self, world_dir: Path): 87 + forge = CultureForge(world_path=world_dir) 88 + assert forge.load("nonexistent") is None 89 + 90 + def test_list_names_empty(self, world_dir: Path): 91 + forge = CultureForge(world_path=world_dir) 92 + assert forge.list_names() == [] 93 + 94 + def test_list_names_after_forge(self, world_dir: Path): 95 + forge = CultureForge(world_path=world_dir) 96 + names = [forge.forge(seed=s).name for s in range(3)] 97 + assert sorted(names) == forge.list_names() 98 + 99 + 100 + class TestGenerator: 101 + @pytest.fixture 102 + def populated_world(self, tmp_path: Path) -> Path: 103 + world = tmp_path / "world" 104 + forge = CultureForge(world_path=world) 105 + forge.forge(seed=42, feel="coastal") 106 + forge.forge(seed=99, feel="highborn") 107 + return world 108 + 109 + def test_sample_returns_names(self, populated_world: Path): 110 + gen = Generator(world_path=populated_world) 111 + cultures = gen.list_cultures() 112 + assert cultures 113 + names = gen.sample(culture=cultures[0].name, count=5) 114 + assert len(names) == 5 115 + assert all(isinstance(n, str) for n in names) 116 + 117 + def test_sample_distinct(self, populated_world: Path): 118 + gen = Generator(world_path=populated_world) 119 + cultures = gen.list_cultures() 120 + names = gen.sample(culture=cultures[0].name, count=10) 121 + assert len(set(names)) == len(names) 122 + 123 + def test_sample_capitalized(self, populated_world: Path): 124 + gen = Generator(world_path=populated_world) 125 + cultures = gen.list_cultures() 126 + for name in gen.sample(culture=cultures[0].name, count=5): 127 + assert name[0].isupper() 128 + 129 + def test_sample_unknown_culture_returns_empty(self, tmp_path: Path): 130 + gen = Generator(world_path=tmp_path) 131 + assert gen.sample(culture="ghost", count=5) == [] 132 + 133 + def test_place_kind(self, populated_world: Path): 134 + gen = Generator(world_path=populated_world) 135 + cultures = gen.list_cultures() 136 + places = gen.sample( 137 + culture=cultures[0].name, count=3, kind="place", 138 + ) 139 + assert len(places) == 3 140 + 141 + def test_list_cultures_returns_loaded(self, populated_world: Path): 142 + gen = Generator(world_path=populated_world) 143 + cultures = gen.list_cultures() 144 + assert len(cultures) == 2 145 + for c in cultures: 146 + assert isinstance(c, ForgedCulture) 147 + 148 + def test_rarity_uncommon(self, populated_world: Path): 149 + gen = Generator(world_path=populated_world) 150 + cultures = gen.list_cultures() 151 + names = gen.sample( 152 + culture=cultures[0].name, count=3, rarity="uncommon", 153 + ) 154 + assert len(names) == 3 155 + 156 + def test_rarity_archaic(self, populated_world: Path): 157 + gen = Generator(world_path=populated_world) 158 + cultures = gen.list_cultures() 159 + names = gen.sample( 160 + culture=cultures[0].name, count=3, rarity="archaic", 161 + ) 162 + assert len(names) == 3
+110
tests/test_names_morphology.py
··· 1 + """Tests for the gender morphology layer.""" 2 + 3 + import random 4 + 5 + import pytest 6 + 7 + from storied.names.forge import Morphology 8 + from storied.names.morphology import _join_with_suffix, apply_morphology 9 + 10 + 11 + @pytest.fixture 12 + def gendered() -> Morphology: 13 + return Morphology( 14 + applies=True, 15 + female_suffixes=["a", "ina"], 16 + male_suffixes=["us", "or"], 17 + neutral_suffixes=["en"], 18 + ) 19 + 20 + 21 + @pytest.fixture 22 + def unmarked() -> Morphology: 23 + return Morphology( 24 + applies=False, 25 + female_suffixes=[], 26 + male_suffixes=[], 27 + neutral_suffixes=[], 28 + ) 29 + 30 + 31 + class TestApplyMorphology: 32 + def test_unmarked_returns_root_unchanged(self, unmarked: Morphology): 33 + rng = random.Random(42) 34 + result = apply_morphology("kara", unmarked, "female", rng) 35 + assert result == "kara" 36 + 37 + def test_bare_probability_can_skip_suffix(self, gendered: Morphology): 38 + # bare_probability=1.0 → always skip 39 + rng = random.Random(42) 40 + result = apply_morphology( 41 + "kara", gendered, "female", rng, bare_probability=1.0, 42 + ) 43 + assert result == "kara" 44 + 45 + def test_female_suffix_applied(self, gendered: Morphology): 46 + rng = random.Random(42) 47 + result = apply_morphology( 48 + "kar", gendered, "female", rng, bare_probability=0.0, 49 + ) 50 + assert result.startswith("kar") 51 + assert any(result.endswith(s) for s in ["a", "ina"]) 52 + 53 + def test_male_suffix_applied(self, gendered: Morphology): 54 + rng = random.Random(42) 55 + result = apply_morphology( 56 + "kar", gendered, "male", rng, bare_probability=0.0, 57 + ) 58 + assert any(result.endswith(s) for s in ["us", "or"]) 59 + 60 + def test_neutral_when_gender_is_none(self, gendered: Morphology): 61 + rng = random.Random(42) 62 + result = apply_morphology( 63 + "kar", gendered, None, rng, bare_probability=0.0, 64 + ) 65 + assert result.endswith("en") 66 + 67 + def test_returns_root_when_no_neutral_suffixes(self): 68 + rng = random.Random(42) 69 + morph = Morphology( 70 + applies=True, 71 + female_suffixes=["a"], 72 + male_suffixes=["us"], 73 + neutral_suffixes=[], 74 + ) 75 + result = apply_morphology( 76 + "kar", morph, None, rng, bare_probability=0.0, 77 + ) 78 + assert result == "kar" 79 + 80 + def test_empty_suffix_returns_root(self): 81 + rng = random.Random(42) 82 + morph = Morphology( 83 + applies=True, 84 + female_suffixes=[""], 85 + male_suffixes=[""], 86 + neutral_suffixes=[""], 87 + ) 88 + result = apply_morphology( 89 + "kar", morph, "female", rng, bare_probability=0.0, 90 + ) 91 + assert result == "kar" 92 + 93 + 94 + class TestJoinWithSuffix: 95 + def test_simple_concat(self): 96 + assert _join_with_suffix("kar", "us") == "karus" 97 + 98 + def test_drops_duplicate_at_boundary(self): 99 + # root ends in 'a', suffix starts with 'a' → drop one 100 + assert _join_with_suffix("mara", "a") == "mara" 101 + assert _join_with_suffix("mara", "an") == "maran" 102 + 103 + def test_empty_suffix(self): 104 + assert _join_with_suffix("kar", "") == "kar" 105 + 106 + def test_empty_root(self): 107 + assert _join_with_suffix("", "us") == "us" 108 + 109 + def test_case_insensitive_match(self): 110 + assert _join_with_suffix("Mara", "An") == "Maran"
+122
tests/test_names_tool.py
··· 1 + """Tests for the storied.tools.names MCP adapter.""" 2 + 3 + from pathlib import Path 4 + 5 + import pytest 6 + 7 + from storied.testing import call_tool 8 + from storied.tools import ToolContext 9 + from storied.tools.names import forge_culture as _forge_culture 10 + from storied.tools.names import generate_names as _generate_names 11 + 12 + 13 + def forge_culture(feel: str | None = None) -> str: 14 + return call_tool(_forge_culture, feel=feel) 15 + 16 + 17 + def generate_names( 18 + culture: str, 19 + count: int = 5, 20 + gender: str | None = None, 21 + rarity: str = "common", 22 + kind: str = "person", 23 + ) -> list[str]: 24 + return call_tool( 25 + _generate_names, 26 + culture=culture, 27 + count=count, 28 + gender=gender, 29 + rarity=rarity, 30 + kind=kind, 31 + ) 32 + 33 + 34 + def _extract_culture_name(result: str) -> str: 35 + """Pull the culture name out of the forge tool's confirmation.""" 36 + # Format: "Forged culture 'name' (source: ...). Sample names: ..." 37 + after_quote = result.split("'", 2) 38 + return after_quote[1] if len(after_quote) > 1 else "" 39 + 40 + 41 + class TestForgeCultureTool: 42 + def test_forge_creates_yaml(self, ctx: ToolContext, tmp_path: Path): 43 + result = forge_culture(feel="coastal") 44 + name = _extract_culture_name(result) 45 + yaml_path = tmp_path / "worlds" / ctx.world_id / "cultures" / f"{name}.yaml" 46 + assert yaml_path.exists() 47 + 48 + def test_forge_creates_entity_md(self, ctx: ToolContext, tmp_path: Path): 49 + result = forge_culture(feel="coastal") 50 + name = _extract_culture_name(result) 51 + md_path = tmp_path / "worlds" / ctx.world_id / "cultures" / f"{name}.md" 52 + assert md_path.exists() 53 + 54 + def test_forge_returns_sample_names(self, ctx: ToolContext): 55 + result = forge_culture(feel="coastal") 56 + assert "Sample names:" in result 57 + 58 + def test_forge_no_feel(self, ctx: ToolContext): 59 + result = forge_culture(feel=None) 60 + assert "Forged culture" in result 61 + 62 + def test_forge_indexes_into_search(self, ctx: ToolContext, tmp_path: Path): 63 + forge_culture(feel="coastal") 64 + # The _do_establish call should have upserted the culture into 65 + # the world search index with content_type="cultures". 66 + results = ctx.vector_index.search("freshly-forged culture", limit=5) 67 + assert results 68 + assert any(r.content_type == "cultures" for r in results) 69 + 70 + 71 + class TestGenerateNamesTool: 72 + def _forge_one(self) -> str: 73 + return _extract_culture_name(forge_culture(feel="coastal")) 74 + 75 + def test_generate_returns_names(self, ctx: ToolContext): 76 + culture = self._forge_one() 77 + names = generate_names(culture=culture, count=5) 78 + assert len(names) == 5 79 + 80 + def test_generate_unknown_culture_returns_empty(self, ctx: ToolContext): 81 + names = generate_names(culture="ghostculture", count=5) 82 + assert names == [] 83 + 84 + def test_generate_place_kind(self, ctx: ToolContext): 85 + culture = self._forge_one() 86 + places = generate_names(culture=culture, count=3, kind="place") 87 + assert len(places) == 3 88 + 89 + def test_generate_with_gender(self, ctx: ToolContext): 90 + culture = self._forge_one() 91 + females = generate_names(culture=culture, count=3, gender="female") 92 + males = generate_names(culture=culture, count=3, gender="male") 93 + assert len(females) == 3 94 + assert len(males) == 3 95 + 96 + 97 + class TestNamesRoleSurface: 98 + """The names tools must be visible to dm/seeder/planner only, 99 + NOT arc_architect (whose surface stays at commit_arc + recall).""" 100 + 101 + def _names_for_role(self, role: str) -> set[str]: 102 + import asyncio 103 + 104 + from storied.mcp_server import _compose_server 105 + 106 + async def _gather() -> set[str]: 107 + server = await _compose_server(role) 108 + tools = await server.list_tools() 109 + return { 110 + t.name for t in tools 111 + if t.name in ("forge_culture", "generate_names") 112 + } 113 + return asyncio.run(_gather()) 114 + 115 + @pytest.mark.parametrize("role", ["dm", "planner", "seeder"]) 116 + def test_role_has_both_names_tools(self, role: str): 117 + assert self._names_for_role(role) == { 118 + "forge_culture", "generate_names", 119 + } 120 + 121 + def test_arc_architect_has_no_names_tools(self): 122 + assert self._names_for_role("arc_architect") == set()
+4 -2
tests/test_seeder.py
··· 21 21 return {t.name for t in await server.list_tools()} 22 22 return asyncio.run(_gather()) 23 23 24 - def test_seeder_only_has_establish_and_set_scene(self): 25 - assert self._seeder_names() == {"establish", "set_scene"} 24 + def test_seeder_has_world_building_tools(self): 25 + assert self._seeder_names() == { 26 + "establish", "set_scene", "forge_culture", "generate_names", 27 + } 26 28 27 29 def test_seeder_excludes_disallowed_tools(self): 28 30 names = self._seeder_names()