initial import of legacy npc codebase

This commit is contained in:
evilchili 2022-08-03 00:19:13 -07:00
parent 7a77f847ed
commit db7aa06a80
29 changed files with 3314 additions and 0 deletions

0
npc/__init__.py Normal file
View File

75
npc/cli.py Normal file
View File

@ -0,0 +1,75 @@
from npc.generator.base import generate_npc, npc_type
from npc import languages
from rich import print
import random
import typer
app = typer.Typer()
@app.command()
def npc(ancestry=None, name=None, pronouns=None, title=None,
nickname=None, whereabouts="Unknown", STR=None, DEX=None, CON=None,
INT=None, WIS=None, CHA=None, randomize=False):
"""
Generate a basic NPC.
"""
print(generate_npc(
ancestry=ancestry,
names=name.split() if name else [],
pronouns=pronouns,
title=title,
nickname=nickname,
whereabouts=whereabouts,
STR=STR,
DEX=DEX,
CON=CON,
INT=INT,
WIS=WIS,
CHA=CHA,
randomize=randomize
).character_sheet)
@app.command()
def names(ancestry=None, count=1):
for _ in range(int(count)):
print(npc_type(ancestry)().full_name)
@app.command()
def text(language='common', words=50):
mod = getattr(languages, language, None)
if not mod:
print(f'Unsupported Language: {language}.')
return
lang_class = getattr(mod, language.capitalize(), None)
if not lang_class:
print(f'Unsupported Language: {language} in {mod}.')
return
lang = lang_class()
phrases = []
phrase = []
for word in [lang.word() for _ in range(int(words))]:
phrase.append(str(word))
if len(phrase) >= random.randint(1, 12):
phrases.append(' '.join(phrase))
phrase = []
if phrase:
phrases.append(' '.join(phrase))
paragraph = phrases[0].capitalize()
for phrase in phrases[1:]:
if random.choice([0, 0, 1]):
paragraph = paragraph + random.choice('?!.') + ' ' + phrase.capitalize()
else:
paragraph = paragraph + ', ' + phrase
print(f"{paragraph}.")
if __name__ == '__main__':
app()

View File

377
npc/generator/base.py Normal file
View File

@ -0,0 +1,377 @@
from importlib import import_module
from npc.generator import traits
import os
import glob
import random
import dice
import textwrap
_available_npc_types = {}
def a_or_an(s):
return 'an' if s[0] in 'aeiouh' else 'a'
class BaseNPC:
"""
The base class for NPCs.
"""
# define this on your subclass
language = None
_names = []
def __init__(self, names=[], title=None, pronouns=None, nickname=None, whereabouts='Unknown', randomize=False,
STR=None, DEX=None, CON=None, INT=None, WIS=None, CHA=None):
# identity
self._names = []
self._pronouns = pronouns
self._nickname = nickname
self._title = title
self._whereabouts = whereabouts
# appearance
self._eyes = None
self._hair = None
self._face = None
self._body = None
self._nose = None
self._lips = None
self._teeth = None
self._skin_tone = None
self._skin_color = None
self._facial_hair = None
self._facial_structure = None
self._eyebrows = None
self._age = None
self._voice = None
self._tail = False
self._horns = False
self._fangs = False
self._wings = False
# character
self._flaw = None
self._goal = None
self._personality = None
stats = (10, 10, 10, 10, 10, 10)
if randomize:
stats = self._roll_stats()
self.STR = STR if STR else stats[0]
self.DEX = DEX if DEX else stats[1]
self.CON = CON if DEX else stats[2]
self.INT = INT if DEX else stats[3]
self.WIS = WIS if DEX else stats[4]
self.CHA = CHA if DEX else stats[5]
self._HP = None
def _roll_stats(self):
stats = [15, 14, 13, 12, 10, 8]
random.shuffle(stats)
r = random.random()
if r < 0.3:
i = random.choice(range(len(stats)))
stats[i] += (random.choice([-1, 1]) * random.randint(1, 3))
return stats
@property
def HP(self):
if not self._HP:
self._HP = str(sum(dice.roll('2d8')) + 2) + ' (2d8+2)'
return self._HP
@property
def names(self):
if not self._names:
self._names = [str(x) for x in self.language.person()]
return self._names
@property
def full_name(self):
name = ' '.join([n.capitalize() for n in self.names])
if self.title:
name = self.title.capitalize() + ' ' + name
if self.nickname:
name = f'{name} "{self.nickname}"'
return name
@property
def pronouns(self):
if not self._pronouns:
self._pronouns = random.choice([
'he/him',
'she/her',
'they/they',
])
return self._pronouns
@property
def title(self):
return self._title
@property
def nickname(self):
if self._nickname is None and hasattr(self.language, 'nicknames'):
try:
self._nickname = random.choice(self.language.nicknames).capitalize()
except IndexError:
self._nickname = False
return self._nickname
@property
def whereabouts(self):
return self._whereabouts
@property
def flaw(self):
if self._flaw is None:
self._flaw = random.choice(traits.flaws)
return self._flaw
@property
def goal(self):
if self._goal is None:
self._goal = random.choice(traits.goals)
return self._goal
@property
def personality(self):
if self._personality is None:
self._personality = ', '.join([
random.choice(traits.personality),
random.choice(traits.personality),
random.choice(traits.personality),
])
return self._personality
@property
def eyes(self):
if self._eyes is None:
self._eyes = ', '.join([random.choice(traits.eye_shape), random.choice(traits.eye_color)])
return self._eyes
@property
def skin_color(self):
if self._skin_color is None:
self._skin_color = random.choice(traits.skin_color)
return self._skin_color
@property
def skin_tone(self):
if self._skin_tone is None:
self._skin_tone = random.choice(traits.skin_tone)
return self._skin_tone
@property
def hair(self):
if self._hair is None:
self._hair = ' '.join([random.choice(traits.hair_style), random.choice(traits.hair_color)])
return self._hair
@property
def face(self):
if not self._face:
self._face = random.choice(traits.face)
return self._face
@property
def facial_structure(self):
if self._facial_structure is None:
self._facial_structure = random.choice(traits.facial_structure)
return self._facial_structure
@property
def lips(self):
if self._lips is None:
self._lips = random.choice(traits.lips)
return self._lips
@property
def teeth(self):
if self._teeth is None:
self._teeth = random.choice(traits.teeth)
return self._teeth
@property
def nose(self):
if self._nose is None:
self._nose = random.choice(traits.nose)
return self._nose
@property
def eyebrows(self):
if self._eyebrows is None:
self._eyebrows = random.choice(traits.eyebrows)
return self._eyebrows
@property
def facial_hair(self):
if self._facial_hair is None:
self._facial_hair = random.choice(traits.facial_hair)
return self._facial_hair
@property
def body(self):
if self._body is None:
self._body = random.choice(traits.body)
return self._body
@property
def tail(self):
if self._tail is None:
self._tail = random.choice(traits.tail)
return self._tail
@property
def horns(self):
if self._horns is None:
self._horns = random.choice(traits.horns)
return self._horns
@property
def wings(self):
if self._wings is None:
self._wings = random.choice(traits.wings)
return self._wings
@property
def fangs(self):
if self._fangs is None:
self._fangs = random.choice(traits.fangs)
return self._fangs
@property
def age(self):
if not self._age:
self._age = random.choice(traits.age)
return self._age
@property
def voice(self):
if not self._voice:
self._voice = random.choice(traits.voice)
return self._voice
@property
def description(self):
desc = (
f"{self.full_name} ({self.pronouns}) is {a_or_an(self.age)} {self.age}, {self.body} "
f"{self.ancestry.lower()} with {self.hair} hair, {self.eyes} eyes and {self.skin_color} skin."
)
trait = None
while not trait:
trait = random.choice([
f'{self.eyebrows} eyebrows' if self.eyebrows else None,
self.facial_hair if self.facial_hair else None,
f'a {self.nose} nose' if self.nose else None,
f'{self.lips} lips' if self.lips else None,
f'{self.teeth} teeth' if self.teeth else None,
self.facial_structure if self.facial_structure else None,
])
desc = desc + ' ' + f"Their face is {self.face}, with {trait}."
if self.tail:
desc = desc + f" Their tail is {self.tail}."
if self.horns:
desc = desc + f" Their horns are {self.horns}."
return desc
@property
def character_sheet(self):
desc = '\n'.join(textwrap.wrap(self.description, width=120))
return f"""\
{desc}
Physical Traits:
Face: {self.face}, {self.eyebrows} eyebrows, {self.nose} nose, {self.lips} lips,
{self.teeth} teeth, {self.facial_hair}
Eyes: {self.eyes}
Skin: {self.skin_tone}, {self.skin_color}
Hair: {self.hair}
Body: {self.body}
Tail: {self.tail}
Horns: {self.horns}
Fangs: {self.fangs}
Wings: {self.wings}
Voice: {self.voice}
Stats:
AC 10
HP {self.HP}
STR {self.STR}
DEX {self.DEX}
CON {self.CON}
INT {self.INT}
WIS {self.WIS}
CHA {self.CHA}
Details:
Personality: {self.personality}
Flaw: {self.flaw}
Goal: {self.goal}
Whereabouts: {self.whereabouts}
"""
def __repr__(self):
return f"{self.full_name}"
def available_npc_types():
"""
Load all available NPC submodules and return a dictionary keyed by module name.
"""
if not _available_npc_types:
for filename in glob.glob(os.path.join(os.path.dirname(os.path.abspath(__file__)), '*.py')):
module_name = os.path.basename(filename)[:-3]
if module_name not in ['base', '__init__', 'traits']:
_available_npc_types[module_name] = import_module(f'npc.generator.{module_name}').NPC
return _available_npc_types
def npc_type(ancestry=None):
"""
Return the NPC class for the specified ancestry, or a random one.
"""
if not ancestry:
non_humans = [x for x in available_npc_types() if x != 'human']
if random.random() <= 0.7:
ancestry = 'human'
else:
ancestry = random.choice(non_humans)
return available_npc_types()[ancestry]
def generate_npc(ancestry=None, names=[], pronouns=None, title=None, nickname=None, whereabouts="Unknown",
STR=0, DEX=0, CON=0, INT=0, WIS=0, CHA=0, randomize=False):
"""
Return a randomized NPC. Any supplied keyword parameters will override the generated values.
By default, NPC stats are all 10 (+0). If randomize is True, the NPC will be given random stats from the standard
distribution, but overrides will still take precedence.
"""
return npc_type(ancestry)(
names=names,
pronouns=pronouns,
title=title,
nickname=nickname,
whereabouts=whereabouts,
STR=STR,
DEX=DEX,
CON=CON,
INT=INT,
WIS=WIS,
CHA=CHA,
randomize=randomize
)

101
npc/generator/dragon.py Normal file
View File

@ -0,0 +1,101 @@
from npc.languages import draconic
from npc.generator.base import BaseNPC, a_or_an
from npc.generator import traits
import textwrap
import random
class NPC(BaseNPC):
ancestry = 'Dragon'
language = draconic.Dragon()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._tail = None
self._horns = None
self._fangs = None
self._wings = None
@property
def nickname(self):
if not self._nickname:
self._nickname = "the " + random.choice(traits.personality)
return self._nickname
@property
def age(self):
if not self._age:
self._age = random.choice([
'wyrmling',
'young',
'adult',
'ancient',
])
return self._age
@property
def pronouns(self):
if not self._pronouns:
self._pronouns = 'they/they'
return self._pronouns
@property
def skin_color(self):
if not self._skin_color:
self._skin_color = random.choice([
'red',
'white',
'green',
'black',
'blue',
'brass',
'bronze',
'copper',
'silver',
'gold',
])
return self._skin_color
@property
def description(self):
trait = random.choice([
f'{self.eyes} eyes',
f'{self.tail} tail',
f'{self.eyebrows} eyebrows',
f'{self.teeth} fangs',
self.facial_structure,
])
return (
f"{self.full_name} ({self.pronouns}) is {a_or_an(self.age)} {self.age} {self.skin_color} "
f"{self.ancestry.lower()} with {a_or_an(self.nose)} {self.nose} snout, {self.body} body and {trait}."
)
@property
def character_sheet(self):
desc = '\n'.join(textwrap.wrap(self.description, width=120))
return f"""\
{desc}
Physical Traits:
Face: {self.face}, {self.eyebrows} eyebrows, {self.nose} nose, {self.lips} lips,
{self.teeth} teeth, {self.facial_hair}
Eyes: {self.eyes}
Skin: {self.skin_tone}, {self.skin_color}
Hair: {self.hair}
Body: {self.body}
Tail: {self.tail}
Voice: {self.voice}
Details:
Personality: {self.personality}
Flaw: {self.flaw}
Goal: {self.goal}
Whereabouts: {self.whereabouts}
"""

15
npc/generator/drow.py Normal file
View File

@ -0,0 +1,15 @@
from npc.languages import undercommon
from npc.generator.base import BaseNPC
class NPC(BaseNPC):
ancestry = 'Drow'
language = undercommon.DrowPerson()
@property
def full_name(self):
return ' '.join([
str(self.names[0]).capitalize(),
str(self.names[1]).capitalize()
])

12
npc/generator/dwarf.py Normal file
View File

@ -0,0 +1,12 @@
from npc.languages import dwarvish
from npc.generator.base import BaseNPC
class NPC(BaseNPC):
ancestry = 'Dwarf'
language = dwarvish.Dwarvish()
@property
def full_name(self):
return ' '.join([str(x).capitalize() for x in self.language.person()])

16
npc/generator/elf.py Normal file
View File

@ -0,0 +1,16 @@
from npc.languages import elven
from npc.generator.base import BaseNPC
class NPC(BaseNPC):
ancestry = 'Elf'
language = elven.ElvenPerson()
@property
def full_name(self):
return ' '.join([
str(self.names[0]).capitalize(),
str(self.names[1]).lower(),
str(self.names[2]).capitalize()
])

10
npc/generator/halfling.py Normal file
View File

@ -0,0 +1,10 @@
import random
from npc.languages import halfling
from npc.generator.base import BaseNPC
class NPC(BaseNPC):
ancestry = 'Halfling'
language = halfling.Halfling()

12
npc/generator/halforc.py Normal file
View File

@ -0,0 +1,12 @@
from npc.languages import orcish
from npc.generator.base import BaseNPC
class NPC(BaseNPC):
ancestry = 'Half-Orc'
language = orcish.HalfOrcPerson()
@property
def full_name(self):
return ' '.join([str(x).capitalize() for x in self.language.person()])

16
npc/generator/highelf.py Normal file
View File

@ -0,0 +1,16 @@
from npc.languages import elven
from npc.generator.base import BaseNPC
class NPC(BaseNPC):
ancestry = 'Elf'
language = elven.HighElvenPerson()
@property
def full_name(self):
return ' '.join([
str(self.names[0]).capitalize(),
str(self.names[1]).lower(),
str(self.names[2]).capitalize()
])

View File

@ -0,0 +1,6 @@
from npc.languages import infernal
from npc.generator import tiefling
class NPC(tiefling.NPC):
language = infernal.HighTiefling()

12
npc/generator/human.py Normal file
View File

@ -0,0 +1,12 @@
from npc.languages import common
from npc.generator.base import BaseNPC
class NPC(BaseNPC):
ancestry = 'Human'
language = common.CommonPerson()
@property
def full_name(self):
return ' '.join([str(x).capitalize() for x in self.language.person()])

39
npc/generator/tiefling.py Normal file
View File

@ -0,0 +1,39 @@
from npc.languages import infernal
from npc.generator.base import BaseNPC
import random
class NPC(BaseNPC):
ancestry = 'Tiefling'
language = infernal.Tiefling()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._tail = None
self._horns = None
@property
def skin_color(self):
if not self._skin_color:
self._skin_color = random.choice([
'reddish',
'white',
'green',
'black',
'blue',
'brassy',
'bronze',
'coppery',
'silvery',
'gold',
])
return self._skin_color
@property
def full_name(self):
name = ' '.join([n.capitalize() for n in self.names])
if self.title:
name = self.title.capitalize() + ' ' + name
if self.nickname:
name = name + ' ' + self.nickname.capitalize()
return name

1580
npc/generator/traits.py Normal file

File diff suppressed because it is too large Load Diff

27
npc/languages/__init__.py Normal file
View File

@ -0,0 +1,27 @@
from npc.languages import abyssal
from npc.languages import celestial
from npc.languages import common
from npc.languages import draconic
from npc.languages import dwarvish
from npc.languages import elven
from npc.languages import gnomish
from npc.languages import halfling
from npc.languages import infernal
from npc.languages import orcish
from npc.languages import undercommon
__ALL__ = [
'abyssal',
'base',
'celestial',
'common',
'draconic',
'dwarvish',
'elven',
'gnomish',
'halfling',
'infernal',
'orcish',
'undercommon',
]

32
npc/languages/abyssal.py Normal file
View File

@ -0,0 +1,32 @@
from npc.languages.base import BaseLanguage
import re
class Abyssal(BaseLanguage):
vowels = ['a', 'e', 'i', 'o', 'u', 'î', 'ê', 'â', 'û', 'ô', 'ä', 'ö', 'ü', 'äu', 'ȧ', 'ė', '', 'ȯ', '']
consonants = ['c', 'g', 'j', 'k', 'p', 'ss', 't']
syllable_template = ('V', 'v', 'c', 'v')
_invalid_sequences = re.compile(
r'[' + ''.join(vowels) + ']{5}|' +
r'[' + ''.join(consonants) + ']{3}'
)
syllable_weights = [3, 2]
minimum_length = 2
def validate_sequence(self, sequence, total_syllables):
too_short = len(''.join(sequence)) < self.minimum_length
if too_short:
return False
t = ''.join(sequence)
if self._invalid_sequences.match(t):
self._logger.debug(f"Invalid sequence: {t}")
return False
return True

194
npc/languages/base.py Normal file
View File

@ -0,0 +1,194 @@
import random
import logging
from collections import namedtuple
grapheme = namedtuple('Grapheme', ['char', 'weight'])
class LanguageException(Exception):
"""
Thrown when language validators fail.
"""
class SyllableFactory:
def __init__(self, template, weights, prefixes, vowels, consonants, suffixes, affixes):
self.template = template
self.weights = weights
self.grapheme = {
'chars': {
'p': [x.char for x in prefixes],
'c': [x.char for x in consonants],
'v': [x.char for x in vowels],
's': [x.char for x in suffixes],
'a': [x.char for x in affixes]
},
'weights': {
'p': [x.weight for x in prefixes],
'c': [x.weight for x in consonants],
'v': [x.weight for x in vowels],
's': [x.weight for x in suffixes],
'a': [x.weight for x in affixes]
}
}
def _filtered_graphemes(self, key):
return [(k, v) for (k, v) in self.grapheme['chars'].items() if k in key]
def graphemes(self, key='apcvs'):
for _, chars in self._filtered_graphemes(key):
for char in chars:
yield char
def is_valid(self, chars, key='apcvs'):
for grapheme_type, _ in self._filtered_graphemes(key):
if chars in self.grapheme['chars'][grapheme_type]:
return True
return False
def get(self):
"""
Generate a single syllable
"""
syllable = ''
for t in self.template:
if t.islower() and random.random() < 0.5:
continue
if '|' in t:
t = random.choice(t.split('|'))
t = t.lower()
syllable = syllable + random.choices(self.grapheme['chars'][t], self.grapheme['weights'][t])[0]
return syllable
def __str__(self):
return self.get()
class WordFactory:
def __init__(self, language):
self.language = language
def random_syllable_count(self):
return 1 + random.choices(range(len(self.language.syllable.weights)), self.language.syllable.weights)[0]
def get(self):
total_syllables = self.random_syllable_count()
seq = []
while not self.language.validate_sequence(seq, total_syllables):
seq = [self.language.syllable.get()]
while len(seq) < total_syllables - 2:
seq.append(self.language.syllable.get())
if len(seq) < total_syllables:
seq.append(self.language.syllable.get())
return ''.join(seq)
def __str__(self):
return self.get()
class BaseLanguage:
"""
Words are created by combining syllables selected from random phonemes according to templates, each containing one
or more of the following grapheme indicators:
c - an optional consonant
C - a required consonant
v - an optional vowel
V - a required consonant
The simplest possible syllable consists of a single grapheme, and the simplest possible word a single syllable.
Words can also be generated from affixes; these are specified by the special template specifiers 'a'/'A'.
Examples:
('c', 'V') - a syllable consisting of exactly one vowel, possibly preceeded by a single consonant
('C', 'c', 'V', 'v') - a syllable consisting of one or two consonants followed by one or two vowels
('a', 'C', 'V') - a syllable consisting of an optional affix, a consonant and a vowel.
Word length is determined by the number of syllables, which is chosen at random using relative weights:
[2, 2, 1] - Names may contain one, two or three syllables, but are half as likely to contain three.
[0, 1] - Names must have exactly two syllables
"""
affixes = []
vowels = []
consonants = []
prefixes = vowels + consonants
suffixes = vowels + consonants
syllable_template = ('C', 'V')
syllable_weights = [1, 1]
minimum_length = 3
def __init__(self):
self._logger = logging.getLogger()
self.syllable = SyllableFactory(
template=self.syllable_template,
weights=self.syllable_weights,
prefixes=[grapheme(char=c, weight=1) for c in self.__class__.prefixes],
suffixes=[grapheme(char=c, weight=1) for c in self.__class__.suffixes],
vowels=[grapheme(char=c, weight=1) for c in self.__class__.vowels],
consonants=[grapheme(char=c, weight=1) for c in self.__class__.consonants],
affixes=[grapheme(char=c, weight=1) for c in self.__class__.affixes]
)
def _valid_syllable(self, syllable, text, key='apcvs', reverse=False):
length = 0
for seq in reverse(sorted(syllable.graphemes(key=key), key=len)):
length = len(seq)
substr = text[-1 * length:] if reverse else text[0:length]
if substr == seq:
return length
return False
def is_valid(self, text):
for part in text.lower().split(' '):
if part in self.affixes:
continue
if len(part) < self.minimum_length:
self._logger.debug(f"'{part}' too short; must be {self.minimum_length} characters.")
return False
first_offset = self._valid_syllable(self.syllable, text=part, key='p')
if first_offset is False:
self._logger.debug(f"'{part}' is not a valid syllable.")
return False
last_offset = self._valid_syllable(self.last_syllable, text=part, key='s', reverse=True)
if last_offset is False:
self._logger.debug(f"'{part}' is not a valid syllable.")
return False
last_offset = len(part) - last_offset
while first_offset < last_offset:
middle = part[first_offset:last_offset]
new_offset = self._valid_syllable(self.syllable, text=middle, key='cv')
if new_offset is False:
self._logger.debug(f"'{middle}' is not a valid middle sequence.")
return False
first_offset = first_offset + new_offset
return True
def validate_sequence(self, sequence, total_syllables):
return len(''.join(sequence)) > self.minimum_length
def word(self):
return WordFactory(language=self)
def place(self):
return self.word()
def person(self):
return (self.word(), self.word())

View File

@ -0,0 +1,32 @@
from npc.languages.base import BaseLanguage
import re
class Celestial(BaseLanguage):
vowels = ['a', 'e', 'i', 'o', 'u', 'î', 'ê', 'â', 'û', 'ô', 'ä', 'ö', 'ü', 'äu', 'ȧ', 'ė', '', 'ȯ', '']
consonants = ['b', 'sc', 'f', 'h', 'l', 'm', 'n', 'r', 's', 'v']
syllable_template = ('V', 'v', 'c', 'c', 'v', 'v')
_invalid_sequences = re.compile(
r'[' + ''.join(vowels) + ']{5}|' +
r'[' + ''.join(consonants) + ']{3}'
)
syllable_weights = [3, 2]
minimum_length = 5
def validate_sequence(self, sequence, total_syllables):
too_short = len(''.join(sequence)) < self.minimum_length
if too_short:
return False
t = ''.join(sequence)
if self._invalid_sequences.match(t):
self._logger.debug(f"Invalid sequence: {t}")
return False
return True

94
npc/languages/common.py Normal file
View File

@ -0,0 +1,94 @@
import re
import random
from npc.languages.base import BaseLanguage, WordFactory
class Common(BaseLanguage):
vowels = ['a', 'e', 'i', 'o', 'u']
consonants = [
'b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', 'q', 'r', 's', 't',
'v', 'w', 'x', 'y', 'z'
]
_middle_clusters = re.compile(
r'bs|ct|ch|ck|dd|ff|gh|gs|ms|ns|ps|qu|rb|rd|rf|rk|rl|rm|rn|rp|rs|rt|ry' +
r'|sh|sk|ss|st|sy|th|tk|ts|tt|ty|ws|yd|yk|yl|ym|yn|yp|yr|ys|yt|yz|mcd' +
r'|[' + ''.join(vowels) + '][' + ''.join(consonants) + ']' +
r'|[' + ''.join(consonants) + '][' + ''.join(vowels) + ']' +
r'|[' + ''.join(vowels) + ']{1,2}'
)
_invalid_sequences = re.compile(
r'[' + ''.join(vowels) + ']{3}|' +
r'[' + ''.join(consonants) + ']{4}'
)
suffixes = [
'ad', 'ed', 'id', 'od', 'ud',
'af', 'ef', 'if', 'of', 'uf',
'ah', 'eh', 'ih', 'oh', 'uh',
'al', 'el', 'il', 'ol', 'ul',
'am', 'em', 'im', 'om', 'um',
'an', 'en', 'in', 'on', 'un',
'ar', 'er', 'ir', 'or', 'ur',
'as', 'es', 'is', 'os', 'us',
'at', 'et', 'it', 'ot', 'ut',
'ax', 'ex', 'ix', 'ox', 'ux',
'ay', 'ey', 'iy', 'oy', 'uy',
'az', 'ez', 'iz', 'oz', 'uz',
]
prefixes = [s[::-1] for s in suffixes]
affixes = []
syllable_template = ('p', 'c|v', 's')
minimum_length = 1
def validate_sequence(self, sequence, total_syllables):
too_short = len(''.join(sequence)) < self.minimum_length
if too_short:
return False
t = ''.join(sequence)
if self._invalid_sequences.match(t):
self._logger.debug(f"Invalid sequence: {t}")
return False
for pos in range(len(t)):
seq = t[pos:pos+2]
if len(seq) != 2:
return True
if not self._middle_clusters.match(seq):
self._logger.debug(f"Invalid sequence: {seq}")
return False
class CommonSurname(Common):
syllable_template = ('P|C', 'v', 'S')
syllable_weights = [1]
word_suffixes = [
'berg', 'borg', 'borough', 'bury', 'berry', 'by', 'ford', 'gard', 'grave', 'grove', 'gren', 'hardt', 'hart',
'heim', 'holm', 'land', 'leigh', 'ley', 'ly', 'lof', 'love', 'lund', 'man', 'mark', 'ness', 'olf', 'olph',
'quist', 'rop', 'rup', 'stad', 'stead', 'stein', 'strom', 'thal', 'thorpe', 'ton', 'vall', 'wich', 'win',
'some', 'smith', 'bridge', 'cope', 'town', 'er', 'don', 'den', 'dell', 'son',
]
def word(self):
return str(WordFactory(self)) + random.choice(self.word_suffixes)
class CommonPerson(Common):
syllable_template = ('p', 'C', 'V', 's')
syllable_weights = [3, 1]
minimum_length = 2
def person(self):
return (WordFactory(language=self), CommonSurname().word())

67
npc/languages/draconic.py Normal file
View File

@ -0,0 +1,67 @@
from npc.languages.base import BaseLanguage, WordFactory
import random
import re
class Draconic(BaseLanguage):
vowels = ["a'", "aa", "ah", "e'", "ee", "ei", "ey", "i'", "ii", "ir", "o'", "u'", "uu"]
consonants = [
'd', 'f', 'g', 'h', 'j', 'k', 'l',
'n', 'r', 's', 't', 'v', 'x', 'y', 'z',
]
syllable_template = ('C', 'V')
_invalid_sequences = re.compile(
r'[' + ''.join(vowels) + ']{3}|' +
r'[' + ''.join(consonants) + ']{4}'
)
syllable_weights = [0, 0, 1, 2, 2, 1]
minimum_length = 3
def validate_sequence(self, sequence, total_syllables):
too_short = len(''.join(sequence)) < self.minimum_length
if too_short:
return False
t = ''.join(sequence)
if self._invalid_sequences.match(t):
self._logger.debug(f"Invalid sequence: {t}")
return False
return True
class Dragon(Draconic):
syllable_template = ('v', 'C', 'V')
syllable_weights = [0, 1, 2]
vowels = ['a', 'e', 'i', 'o', 'u']
last_vowels = vowels
last_consonants = ['th', 'x', 'ss', 'z']
minimum_length = 2
_invalid_sequences = re.compile(
r'[' + ''.join(last_vowels) + ']{2}|' +
r'[' + ''.join(Draconic.consonants) + ']{2}'
)
def names(self):
prefix = str(WordFactory(self))
suffix = ''
while not self.validate_sequence(suffix, 1):
suffix = ''.join([
random.choice(self.last_vowels),
random.choice(self.last_consonants),
random.choice(['us', 'ux', 'as', 'ax', 'is', 'ix', 'es', 'ex'])
])
return [prefix + suffix]
person = names

42
npc/languages/dwarvish.py Normal file
View File

@ -0,0 +1,42 @@
import random
from npc.languages.base import BaseLanguage
class Dwarvish(BaseLanguage):
consonants = [
'b', 'p', 'ph', 'd', 't', 'th', 'j', 'c', 'ch', 'g', 'k', 'kh', 'v', 'f', 'z', 's', 'zh', 'sh', 'hy', 'h', 'r',
'l', 'y', 'w', 'm', 'n'
]
vowels = [
'a', 'e', 'i', 'o', 'u', 'î', 'ê', 'â', 'û', 'ô'
]
affixes = []
first_consonants = consonants
first_vowels = vowels
first_affixes = affixes
last_vowels = vowels
last_consonants = consonants
last_affixes = affixes
syllable_template = ('C', 'V', 'c')
syllable_weights = [4, 1]
name_suffixes = ['son', 'sson', 'zhon', 'dottir', 'dothir', 'dottyr']
def person(self):
words = super().person()
suffix = random.choice(Dwarvish.name_suffixes)
return (str(words[0]), f"{words[1]}{suffix}")
def is_valid(self, text):
for suffix in self.name_suffixes:
if text.endswith(suffix):
text = text[0:len(suffix)]
break
return super().is_valid(text)

166
npc/languages/elven.py Normal file
View File

@ -0,0 +1,166 @@
import random
import re
from npc.languages.base import BaseLanguage, WordFactory
class Elven(BaseLanguage):
"""
Phonetics for the Elven language in Telisar. Inspired by Tolkein's Quenya language, but with naming conventions
following Twirrim's conventions in-game.
"""
vowels = ['a', 'e', 'i', 'o', 'u']
consonants = ['b', 'c', 'd', 'f', 'g', 'h', 'k', 'l', 'm', 'n', 'p', 'r', 's', 't', 'v', 'w', 'y', 'z']
affixes = []
first_vowels = ['a', 'e', 'i', 'o', 'u', 'y']
first_consonants = ['c', 'g', 'l', 'm', 'n', 'r', 's', 't', 'v', 'z']
first_affixes = []
last_vowels = ['a', 'i', 'e']
last_consonants = ['t', 's', 'm', 'n', 'l', 'r', 'd', 'a', 'th']
last_affixes = []
syllable_template = ('c', 'v', 'c', 'V', 'C', 'v')
minimum_length = 4
_valid_consonant_sequences = [
'cc', 'ht', 'kd', 'kl', 'km', 'kp', 'kt', 'kv', 'kw', 'ky', 'lc', 'ld',
'lf', 'll', 'lm', 'lp', 'lt', 'lv', 'lw', 'ly', 'mb', 'mm', 'mp', 'my',
'nc', 'nd', 'ng', 'nn', 'nt', 'nw', 'ny', 'ps', 'pt', 'rc', 'rd', 'rm',
'rn', 'rp', 'rr', 'rs', 'rt', 'rw', 'ry', 'sc', 'ss', 'ts', 'tt', 'th',
'tw', 'ty'
]
_invalid_sequences = re.compile(
r'[' + ''.join(vowels) + ']{3}|' +
r'[' + ''.join(consonants) + ']{4}'
)
def validate_sequence(self, sequence, *args, **kwargs):
"""
Ensure the specified sequence of syllables results in valid letter combinations.
"""
too_short = len(''.join(sequence)) < self.minimum_length
if too_short:
return False
# the whole string must be checked against the invalid sequences pattern
chars = ''.join(sequence)
if self._invalid_sequences.match(chars):
self._logger.debug(f"Invalid sequence: {chars}")
return False
# Now step through the sequence, two letters at a time, and verify that
# all pairs of consonants are valid.
for offset in range(0, len(chars), 2):
seq = chars[offset:2]
if not seq:
break
if seq[0] in self.consonants and seq[1] in self.consonants:
if seq not in self._valid_consonant_sequences:
self._logger.debug(f"Invalid sequence: {seq}")
return False
return True
class ElvenPlaceName(Elven):
"""
Place names are a restricted subset of Elven; the initial syllables are constructed as normal, but place names
end in a sequence consisting of exactly one vowel and one consonant.
"""
syllable_template = ('v', 'C', 'v')
syllable_weights = [2, 1]
first_consonants = Elven.first_consonants + ['q']
minimum_length = 2
affixes = ['el']
def word(self):
prefix = str(WordFactory(self))
suffix = []
while not self.validate_sequence(suffix):
suffix = [
random.choice(self.last_vowels),
random.choice(self.last_consonants + ['ss']),
]
return prefix + ''.join(suffix)
def full_name(self):
return 'el '.join(self.names)
class HighElvenSurname(Elven):
"""
High Elven names follow the same naming conventions as more modern names, but ancient place names were longer, and
suffixes always followed a pattern of vowel, consonant, two vowels, and a final consonant, but the rules for
each are much more restrictive. In practice just a few suffixes are permitted: ieth, ies, ier, ien, iath, ias, iar,
ian, ioth, ios, ior, and ion.
"""
syllable_template = ('v', 'C', 'v')
syllable_weights = [1, 2, 2]
minimum_length = 2
def word(self):
prefix = str(WordFactory(self))
suffix = ''
while not self.validate_sequence(suffix):
suffix = ''.join([
random.choice(self.last_vowels),
random.choice(self.last_consonants + ['ss']),
random.choice([
'ie',
'ia',
'io',
]),
random.choice(['th', 's', 'r', 'n'])
])
return prefix + suffix
class ElvenPerson(Elven):
"""
A modern Elven name. Surnames follow the same convention as High Elven in including place names, though over time
the social function of denoting where renown was earned has been lost. An elf who names himself "am Uman", for
example, would be seen as either foolish or obnoxious, or both. Like "Johnny New York."
"""
syllable_template = ('c', 'V', 'C', 'v')
syllable_weights = [1, 2]
last_affixes = ['am', 'an', 'al', 'um']
def place(self):
return ElvenPlaceName().word()
def word(self):
return (
super().word(),
random.choice(self.last_affixes),
self.place()
)
person = word
class HighElvenPerson(ElvenPerson):
"""
Given names in High Elven and modern Elven follow the same conventions, but a High Elven surname is generally
chosen by the individual, to indicate "the place where renown is earned." So the High-Elven Elstuviar am
Vakaralithien implies a place or organization named Vakarlithien where the elf Elstuviar was first recognized by
their peers for worthy accompliments.
"""
syllable_weights = [2, 2, 2]
def word(self):
return (
super(Elven, self).word(),
random.choice(self.last_affixes),
HighElvenSurname().word()
)
person = word

24
npc/languages/gnomish.py Normal file
View File

@ -0,0 +1,24 @@
from npc.languages.base import BaseLanguage
class Gnomish(BaseLanguage):
vowels = ['a', 'e', 'i', 'o', 'u', 'y']
consonants = ['b', 'd', 'f', 'g', 'h', 'j', 'l', 'm', 'n', 'p', 'r', 's', 't', 'v', 'w', 'z']
affixes = []
first_vowels = vowels
first_consonants = consonants
first_affixes = affixes
last_vowels = ['a', 'e', 'i', 'o', 'y']
last_consonants = consonants
last_affixes = affixes
syllable_template = ('C', 'V', 'v')
syllable_weights = [3, 1]
minimum_length = 1
def person(self):
return (self.word(), self.word())

53
npc/languages/halfling.py Normal file
View File

@ -0,0 +1,53 @@
from npc.languages.base import BaseLanguage
class Halfling(BaseLanguage):
vowels = ["a'", "e'", "i'" "o'", 'a', 'e', 'i', 'o', 'y']
consonants = ['b', 'd', 'f', 'g', 'h', 'j', 'l', 'm', 'n', 'p', 'r', 's', 't', 'v', 'w', 'z']
affixes = []
first_vowels = vowels
first_consonants = consonants
first_affixes = affixes
last_vowels = ['a', 'e', 'i', 'o', 'y']
last_consonants = consonants
last_affixes = affixes
syllable_template = ('c', 'V')
syllable_weights = [0, 1, 2, 3, 2, 1]
nicknames = [
'able', 'clean', 'enthusiastic', 'heartening', 'meek', 'reasonable', 'talented',
'accommodating', 'clever', 'ethical', 'helpful', 'meritorious', 'refined', 'temperate',
'accomplished', 'commendable', 'excellent', 'moral', 'reliable', 'terrific',
'adept', 'compassionate', 'exceptional', 'honest', 'neat', 'remarkable', 'tidy',
'admirable', 'composed', 'exemplary', 'honorable', 'noble', 'resilient', 'quality',
'agreeable', 'considerate', 'exquisite', 'hopeful', 'obliging', 'respectable', 'tremendous',
'amazing', 'consummate', 'extraordinary', 'humble', 'observant', 'respectful', 'trustworthy',
'appealing', 'cooperative', 'fabulous', 'important', 'optimistic', 'resplendent', 'trusty',
'astute', 'correct', 'faithful', 'impressive', 'organized', 'responsible', 'truthful',
'attractive', 'courageous', 'fantastic', 'incisive', 'outstanding', 'robust', 'unbeatable',
'awesome', 'courteous', 'fascinating', 'incredible', 'peaceful', 'selfless', 'understanding',
'beautiful', 'dazzling', 'fine', 'innocent', 'perceptive', 'sensational', 'unequaled',
'benevolent', 'decent', 'classy', 'insightful', 'perfect', 'sensible', 'unparalleled',
'brave', 'delightful', 'fortitudinous', 'inspiring', 'pleasant', 'serene', 'upbeat',
'breathtaking', 'dependable', 'gallant', 'intelligent', 'pleasing', 'sharp', 'valiant',
'bright', 'devoted', 'generous', 'joyful', 'polite', 'shining', 'valuable',
'brilliant', 'diplomatic', 'gentle', 'judicious', 'positive', 'shrewd', 'vigilant',
'bubbly', 'discerning', 'gifted', 'just', 'praiseworthy', 'smart', 'vigorous',
'buoyant', 'disciplined', 'giving', 'kindly', 'precious', 'sparkling', 'virtuous',
'calm', 'elegant', 'gleaming', 'laudable', 'priceless', 'spectacular', 'well mannered',
'capable', 'elevating', 'glowing', 'likable', 'principled', 'splendid', 'wholesome',
'charitable', 'enchanting', 'good', 'lovable', 'prompt', 'steadfast', 'wise',
'charming', 'encouraging', 'gorgeous', 'lovely', 'prudent', 'stunning', 'witty',
'chaste', 'endearing', 'graceful', 'loyal', 'punctual', 'super', 'wonderful',
'cheerful', 'energetic', 'gracious', 'luminous', 'pure', 'superb', 'worthy',
'chivalrous', 'engaging', 'great', 'magnanimous', 'quick', 'superior', 'zesty',
'gallant', 'enhanced', 'happy', 'magnificent', 'radiant', 'supportive',
'civil', 'enjoyable', 'hardy', 'marvelous', 'rational', 'supreme'
]
def person(self):
return (self.word(), self.word(), self.word())

108
npc/languages/infernal.py Normal file
View File

@ -0,0 +1,108 @@
from npc.languages.base import BaseLanguage
import random
import re
class Infernal(BaseLanguage):
vowels = ['a', 'e', 'i', 'o', 'u']
consonants = [
'b', 'c', 'd', 'f', 'g', 'j', 'k', 'l', 'm',
'n', 'p', 'r', 's', 't', 'v', 'x', 'y', 'z',
"t'h", "t'j", "t'z", "x't", "x'z", "x'j"
]
syllable_template = ('C', 'V')
_invalid_sequences = re.compile(
r'[' + ''.join(vowels) + ']{3}|' +
r'[' + ''.join(consonants) + ']{4}'
)
syllable_weights = [3, 2]
minimum_length = 1
def validate_sequence(self, sequence, total_syllables):
too_short = len(''.join(sequence)) < self.minimum_length
if too_short:
return False
t = ''.join(sequence)
if self._invalid_sequences.match(t):
self._logger.debug(f"Invalid sequence: {t}")
return False
return True
class Tiefling(Infernal):
"""
Tiefling names are formed using an infernal root and a few common suffixes.
"""
nicknames = [
'eternal',
'wondrous',
'luminous',
'perfect',
'essential',
'golden',
'unfailing',
'perpetual',
'infinite',
'exquisite',
'sinless',
'ultimate',
'flawless',
'timeless',
'glorious',
'absolute',
'boundless',
'true',
'incredible',
'virtuous',
'supreme',
'enchanted',
'magnificent',
'superior',
'spectacular',
'divine',
] + ['' for _ in range(50)]
def person(self):
suffix = random.choice([
'us',
'ius'
'to',
'tro'
'eus',
'a',
'an',
'is',
])
return [str(self.word()) + suffix]
class HighTiefling(Tiefling):
"""
"High" Tieflings revere their bloodlines and take their lineage as part of their name.
"""
nicknames = []
def person(self):
bloodline = random.choice([
'Asmodeus',
'Baalzebul',
'Rimmon',
'Dispater',
'Fierna',
'Glasya',
'Levistus',
'Mammon',
'Mephistopheles',
'Zariel',
])
return [bloodline] + super().person()

57
npc/languages/orcish.py Normal file
View File

@ -0,0 +1,57 @@
import re
from npc.languages.base import BaseLanguage
class Orcish(BaseLanguage):
vowels = ['a', 'e', 'i', 'o', 'u']
consonants = ['b', 'c', 'ch', 'd', 'f', 'h', 'k', 'm', 'n', 'p', 'r', 's', 'sh', 't', 'z']
affixes = []
first_vowels = vowels
first_consonants = consonants
first_affixes = affixes
last_vowels = vowels
last_consonants = consonants
last_affixes = affixes
syllable_template = ('C', 'c', 'V')
syllable_weights = [2, 4, 0.5]
_middle_clusters = re.compile(
r'\S?[' +
r'bd|bk|br|bs|' +
r'ch|ck|cp|cr|cs|ct|' +
r'db|dk|ds|' +
r'fr|ft|' +
r'kr|ks|kz|' +
r'ms|' +
r'ns|nt|nz|' +
r'ps|pt|' +
r'rk|rt|rz|' +
r'sc|sh|sk|sr|st|' +
r'tc|th|tr|ts|tz' +
r']\S?'
)
def validate_sequence(self, sequence, total_syllables):
too_short = len(''.join(sequence)) < self.minimum_length
if too_short:
return False
seq = ''.join(sequence[-2:])
if not self._middle_clusters.match(seq):
self._logger.debug(f"Invalid sequence: {sequence[-2:]}")
return False
return True
class OrcishPerson(Orcish):
pass
class HalfOrcPerson(Orcish):
syllable_template = ('C', 'V', 'c')
first_consonants = ['b', 'c', 'd', 'k', 'p', 't', 'z']
last_consonants = Orcish.consonants + ['sht', 'cht', 'zt', 'zch']

View File

@ -0,0 +1,122 @@
import random
import re
from npc.languages.base import BaseLanguage, WordFactory
class Undercommon(BaseLanguage):
vowels = ['a', 'e', 'i', 'o', 'u', 'a', 'e', 'i', 'o', 'u', 'ä', 'ö', 'ü', 'äu']
consonants = ['b', 'c', 'd', 'f', 'g', 'h', 'k', 'l', 'm', 'n', 'p', 'r', 's', 't', 'v', 'w', 'y', 'z']
affixes = []
first_vowels = ['a', 'e', 'i', 'o', 'u', 'y']
first_consonants = ['c', 'g', 'l', 'm', 'n', 'r', 's', 't', 'v', 'z']
first_affixes = []
last_vowels = ['a', 'i', 'e']
last_consonants = ['t', 's', 'm', 'n', 'l', 'r', 'd', 'a', 'th']
last_affixes = []
syllable_template = ('c', 'v', 'c', 'V', 'C', 'v')
minimum_length = 4
_valid_consonant_sequences = [
'cc', 'ht', 'kd', 'kl', 'km', 'kp', 'kt', 'kv', 'kw', 'ky', 'lc', 'ld',
'lf', 'll', 'lm', 'lp', 'lt', 'lv', 'lw', 'ly', 'mb', 'mm', 'mp', 'my',
'nc', 'nd', 'ng', 'nn', 'nt', 'nw', 'ny', 'ps', 'pt', 'rc', 'rd', 'rm',
'rn', 'rp', 'rr', 'rs', 'rt', 'rw', 'ry', 'sc', 'ss', 'ts', 'tt', 'th',
'tw', 'ty'
]
_invalid_sequences = re.compile(
r'[' + ''.join(vowels) + ']{3}|' +
r'[' + ''.join(consonants) + ']{4}'
)
def validate_sequence(self, sequence, *args, **kwargs):
"""
Ensure the specified sequence of syllables results in valid letter combinations.
"""
too_short = len(''.join(sequence)) < self.minimum_length
if too_short:
return False
# the whole string must be checked against the invalid sequences pattern
chars = ''.join(sequence)
if self._invalid_sequences.match(chars):
self._logger.debug(f"Invalid sequence: {chars}")
return False
# Now step through the sequence, two letters at a time, and verify that
# all pairs of consonants are valid.
for offset in range(0, len(chars), 2):
seq = chars[offset:2]
if not seq:
break
if seq[0] in self.consonants and seq[1] in self.consonants:
if seq not in self._valid_consonant_sequences:
self._logger.debug(f"Invalid sequence: {seq}")
return False
return True
class DrowPlaceName(Undercommon):
syllable_template = ('v', 'C', 'v')
syllable_weights = [2, 1]
first_consonants = Undercommon.first_consonants + ['q']
minimum_length = 2
affixes = ['el']
def word(self):
prefix = str(WordFactory(self))
suffix = []
while not self.validate_sequence(suffix):
suffix = [
random.choice(self.last_vowels),
random.choice(self.last_consonants + ['ss']),
]
return prefix + ''.join(suffix)
def full_name(self):
return 'el '.join(self.names)
class DrowSurname(Undercommon):
syllable_template = ('v', 'C', 'v')
syllable_weights = [1, 2, 2]
minimum_length = 2
def word(self):
prefix = str(WordFactory(self))
suffix = ''
while not self.validate_sequence(suffix):
suffix = ''.join([
random.choice(self.last_vowels),
random.choice(self.last_consonants + ['ss']),
random.choice([
'ie',
'ia',
'io',
]),
random.choice(['th', 's', 'r', 'n'])
])
return prefix + suffix
class DrowPerson(Undercommon):
syllable_template = ('c', 'V', 'C', 'v')
syllable_weights = [1, 2]
def place(self):
return DrowPlaceName().word()
def word(self):
return (
super().word(),
DrowSurname().word(),
)
person = word

25
pyproject.toml Normal file
View File

@ -0,0 +1,25 @@
[tool.poetry]
name = "dnd_npcs"
version = "0.1.0"
description = "NPC tools for the telisar homebrew campaign setting"
authors = ["evilchili <evilchili@gmail.com>"]
license = "The Unlicense"
packages = [
{ include = 'npc' }
]
[tool.poetry.dependencies]
python = "^3.10"
typer = "latest"
rich = "latest"
dice = "latest"
[tool.poetry.dev-dependencies]
pytest = "latest"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
npc = "npc.cli:app"