diff --git a/npc/__init__.py b/npc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/npc/cli.py b/npc/cli.py new file mode 100644 index 0000000..fd25070 --- /dev/null +++ b/npc/cli.py @@ -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() diff --git a/npc/generator/__init__.py b/npc/generator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/npc/generator/base.py b/npc/generator/base.py new file mode 100644 index 0000000..d1b6c58 --- /dev/null +++ b/npc/generator/base.py @@ -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 + ) diff --git a/npc/generator/dragon.py b/npc/generator/dragon.py new file mode 100644 index 0000000..0b31fd9 --- /dev/null +++ b/npc/generator/dragon.py @@ -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} + +""" diff --git a/npc/generator/drow.py b/npc/generator/drow.py new file mode 100644 index 0000000..26ba717 --- /dev/null +++ b/npc/generator/drow.py @@ -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() + ]) diff --git a/npc/generator/dwarf.py b/npc/generator/dwarf.py new file mode 100644 index 0000000..14b373f --- /dev/null +++ b/npc/generator/dwarf.py @@ -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()]) diff --git a/npc/generator/elf.py b/npc/generator/elf.py new file mode 100644 index 0000000..30d3b7a --- /dev/null +++ b/npc/generator/elf.py @@ -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() + ]) diff --git a/npc/generator/halfling.py b/npc/generator/halfling.py new file mode 100644 index 0000000..c9cddcf --- /dev/null +++ b/npc/generator/halfling.py @@ -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() diff --git a/npc/generator/halforc.py b/npc/generator/halforc.py new file mode 100644 index 0000000..cb4c896 --- /dev/null +++ b/npc/generator/halforc.py @@ -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()]) diff --git a/npc/generator/highelf.py b/npc/generator/highelf.py new file mode 100644 index 0000000..f9877c8 --- /dev/null +++ b/npc/generator/highelf.py @@ -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() + ]) diff --git a/npc/generator/hightiefling.py b/npc/generator/hightiefling.py new file mode 100644 index 0000000..83b3eff --- /dev/null +++ b/npc/generator/hightiefling.py @@ -0,0 +1,6 @@ +from npc.languages import infernal +from npc.generator import tiefling + + +class NPC(tiefling.NPC): + language = infernal.HighTiefling() diff --git a/npc/generator/human.py b/npc/generator/human.py new file mode 100644 index 0000000..1e966d7 --- /dev/null +++ b/npc/generator/human.py @@ -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()]) diff --git a/npc/generator/tiefling.py b/npc/generator/tiefling.py new file mode 100644 index 0000000..0eb1436 --- /dev/null +++ b/npc/generator/tiefling.py @@ -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 diff --git a/npc/generator/traits.py b/npc/generator/traits.py new file mode 100644 index 0000000..37f18ba --- /dev/null +++ b/npc/generator/traits.py @@ -0,0 +1,1580 @@ +# physical traits source: https://www.bryndonovan.com/ +eye_shape = [ + 'large', + 'small', + 'narrow', + 'sharp', + 'squinty', + 'round', + 'wide-set', + 'close-set', + 'deep-set', + 'sunken', + 'bulging', + 'protruding', + 'wide', + 'hooded', + 'heavy-lidded', + 'bedroom', + 'bright', + 'sparkling', + 'glittering', + 'flecked', + 'dull', + 'bleary', + 'rheumy', + 'cloudy', + 'red-rimmed', + 'beady', + 'birdlike', + 'cat-like', + 'jewel-like', + 'steely', + 'hard', + 'long-lashed', + 'thick-lashed', + 'thin-lashed', +] + +eye_color = [ + 'chestnut', + 'chocolate brown', + 'cocoa brown', + 'coffee brown', + 'mocha', + 'mahogany', + 'sepia', + 'sienna brown', + 'mink brown', + 'copper', + 'amber', + 'cognac-colored', + 'whiskey-colored', + 'brandy-colored', + 'honey', + 'tawny', + 'topaz', + 'hazel', + 'obsidian', + 'onyx', + 'coal', + 'raven', + 'midnight', + 'sky blue', + 'sunny blue', + 'cornflower blue', + 'steel blue', + 'ice blue', + 'Arctic blue', + 'glacial blue', + 'crystal blue', + 'cerulean', + 'electric blue', + 'azure', + 'lake blue', + 'aquamarine', + 'turquoise', + 'denim blue', + 'slate blue', + 'slate gray', + 'storm blue', + 'storm gray', + 'smoky gray', + 'ash gray', + 'concrete gray', + 'dove gray', + 'fog gray', + 'gunmetal gray', + 'olive', + 'emerald', + 'leaf green', + 'moss green', +] + +skin_color = [ + 'amber', + 'bronze', + 'cinnamon', + 'copper', + 'dark brown', + 'deep brown', + 'ebony', + 'honey', + 'golden', + 'pale', + 'pallid', + 'pasty', + 'fair', + 'light', + 'creamy', + 'alabaster', + 'ivory', + 'bisque', + 'milky-white', + 'porcelain', + 'chalky', + 'sallow', + 'olive', + 'peach-colored', + 'rosy', + 'ruddy', + 'florid', + 'russet', + 'tawny', + 'fawny', +] + +skin_tone = [ + 'lined', + 'wrinkled', + 'seamed', + 'leathery', + 'sagging', + 'drooping', + 'loose', + 'clear', + 'smooth', + 'silken', + 'satiny', + 'dry', + 'flaky', + 'scaly', + 'delicate', + 'thin', + 'translucent', + 'luminescent', + 'baby-soft', + 'flawless', + 'poreless', + 'with large pores', + 'glowing', + 'dewy', + 'dull', + 'velvety', + 'fuzzy', + 'rough', + 'uneven', + 'mottled', + 'dimpled', + 'doughy', + 'firm', + 'freckled', + 'pimply', + 'pockmarked', + 'blemished', + 'pitted', + 'scarred', + 'bruised', + 'veined', + 'scratched', + 'sunburned', + 'weather-beaten', + 'raw', + 'tattooed', +] + +hair_color = [ + 'black', + 'blue-black', + 'jet black', + 'raven', + 'ebony', + 'inky black', + 'midnight', + 'sable', + 'salt and pepper', + 'silver', + 'charcoal gray', + 'steel gray', + 'white', + 'snow-white', + 'brown', + 'brunette', + 'chocolate-brown', + 'coffee-brown', + 'ash-brown', + 'brown sugar', + 'nut-brown', + 'caramel', + 'tawny-brown', + 'toffee-brown', + 'red', + 'ginger', + 'scarlet', + 'auburn', + 'copper', + 'strawberry blonde', + 'butterscotch', + 'honey', + 'wheat', + 'blonde', + 'golden', + 'sandy blond', + 'flaxen', + 'fair-haired', + 'bleached', + 'platinum', +] + +face = [ + 'square', + 'round', + 'oblong', + 'oval', + 'elongated', + 'narrow', + 'heart-shaped', + 'catlike', + 'wolfish', + 'chiseled', + 'sculpted', + 'craggy', + 'soft', + 'jowly', + ] + +facial_structure = [ + 'a high forehead', + 'a broad forehead', + 'a prominent ridge', + 'a protruding brow', + 'a square jaw', + 'a jutting chin', + 'a pointed chin', + 'a weak chin', + 'a receding chin', + 'a double chin', + 'a cleft chin', + 'a dimpled chin', + 'sharp cheekbones', + 'high cheekbones', + 'angular cheekbones', + 'hollow cheeks', +] + +hair_style = [ + 'long', + 'short', + 'shoulder-length', + 'loose', + 'limp', + 'dull', + 'shiny', + 'glossy', + 'sleek', + 'smooth', + 'luminous', + 'lustrous', + 'spiky', + 'stringy', + 'shaggy', + 'tangled', + 'messy', + 'tousled', + 'windblown', + 'unkempt', + 'bedhead', + 'straggly', + 'neatly combed', + 'parted', + 'slicked down', + 'slicked back', + 'cropped', + 'clipped', + 'buzz cut', + 'crewcut', + 'bob', + 'mullet', + 'curly', + 'bushy', + 'frizzy', + 'wavy', + 'straight', + 'lanky', + 'dry', + 'oily', + 'greasy', + 'layered', + 'corkscrewed', + 'spiraling', + 'ringlets of', + 'braided', + 'widow’s peak', + 'bald', + 'shaved', + 'comb-over', + 'afro', + 'thick', + 'luxuriant', + 'voluminous', + 'full', + 'wild', + 'untamed', + 'bouncy', + 'wispy', + 'fine', + 'thinning', +] + + +body = [ + 'tall', + 'short', + 'petite', + 'tiny', + 'compact', + 'big', + 'large', + 'burly', + 'beefy', + 'bulky', + 'brawny', + 'barrel-chested', + 'heavy', + 'heavy-set', + 'fat', + 'overweight', + 'obese', + 'flabby', + 'chunky', + 'chubby', + 'pudgy', + 'pot-bellied', + 'portly', + 'thick', + 'stout', + 'lush', + 'plush', + 'full-figured', + 'ample', + 'rounded', + 'generous', + 'voluptuous', + 'curvy', + 'hourglass', + 'plump', + 'leggy', + 'long-legged', + 'gangling', + 'lanky', + 'coltish', + 'lissome', + 'willowy', + 'lithe', + 'lean', + 'slim', + 'slender', + 'trim', + 'thin', + 'skinny', + 'emaciated', + 'gaunt', + 'bony', + 'spare', + 'solid', + 'stocky', + 'wiry', + 'rangy', + 'sinewy', + 'stringy', + 'ropy', + 'sturdy', + 'strapping', + 'powerful', + 'hulking', + 'fit', + 'athletic', + 'toned', + 'built', + 'muscular', + 'chiseled', + 'taut', + 'ripped', + 'Herculean', + 'broad-shouldered', + 'sloping shoulders', + 'bowlegged', +] + +facial_hair = [ + 'a scar on the left cheek', + 'a scar on the right cheek', + 'a scar above the left eye', + 'a scar above the right eye', + 'a scar on the chin', + 'no facial hair', + 'a thin beard', + 'a wild beard', + 'an unkept beard', + 'a neatly-trimmed beard', + 'a van dyke beard', + 'a goatee and moustache', + 'a chin-strap beard', + 'a long, braided goatee', + 'a neckbeard', + 'a thin layer of peach fuzz beard', + 'a small mole above the lip', + 'a large mole next to the nose', + 'a goatee', + 'a moustache', + 'sideburns', + 'mutton-chop sideburns', + 'stubble', + 'five o’ clock shadow', +] + +lips = [ + 'thin', + 'narrow', + 'full', + 'lush', + 'Cupid’s bow', + 'rosebud', + 'dry', + 'cracked', + 'chapped', + 'moist', + 'glossy', +] + +teeth = [ + 'straight', + 'gapped', + 'gleaming white', + 'broken', + 'crooked', + 'missing' +] + +nose = [ + 'snub', + 'dainty', + 'button', + 'turned-up', + 'long', + 'broad', + 'thin', + 'straight', + 'pointed', + 'crooked', + 'aquiline', + 'Roman', + 'bulbous', + 'flared', + 'hawk', + 'strong', +] + +eyebrows = [ + 'arched', + 'straight', + 'plucked', + 'sparse', + 'trim', + 'dark', + 'faint', + 'thin', + 'thick', + 'unruly', + 'bushy', + 'heavy', +] + +age = [ + 'young', + 'stripling', + 'adolescent', + 'youthful', + 'youngish', + 'adult', + 'ageless', + 'mature', + 'middle-aged', + 'aging', + 'elderly', + 'old', + 'wizened', + 'ancient', +] + +voice = [ + 'breathy', + 'slow', + 'fast', + 'clipped', + 'drawling', + 'considered', + 'babbling', + 'taciturn', + 'mush-mouth', + 'basso', + 'tenor', + 'soprano' +] + + +# source: http://weirdzine.com/wp-content/uploads/2015/09/Flaws.pdf +flaws = [ + "I judge others harshly, and myself even more severely.", + "I put too much trust in those who wield power within my temple's hierarchy.", + "My piety sometimes leads me to blindly trust those that profess faith in my god.", + "I am inflexible in my thinking.", + "I am suspicious of strangers and suspect the worst of them.", + "Once I pick a goal, I become obsessed with it to the detriment of everything else in my life.", + "I can't resist a pretty face.", + "I'm always in debt. I spend my ill-gotten gains on decadent luxuries faster than I bring them in.", + "I'm convinced that no one could ever fool me in the way I fool others.", + "I'm too greedy for my own good. I can't resist taking a risk if there's money involved.", + "I can't resist swindling people who are more powerful than me.", + "I hate to admit it and will hate myself for it, but I'll run and preserve my own hide if the going gets tough.", + "When I see something valuable, I can't think about anything but how to steal it.", + "When faced with a choice between money and my friends, I usually choose the money.", + "If there's a plan, I'll forget it. If I don't forget it, I'll ignore it.", + "I have a 'tell' that reveals when I'm lying.", + "I turn tail and run when things go bad.", + "An innocent person is in prison for a crime that I committed. I'm okay with that.", + "I'll do anything to win fame and renown.", + "I'm a sucker for a pretty face.", + "A scandal prevents me from ever going home again. That kind of trouble seems to follow me around.", + "I once satirized a noble who still wants my head. It was a mistake that I will likely repeat.", + "I have trouble keeping my true feelings hidden. My sharp tongue lands me in trouble.", + "Despite my best efforts, I am unreliable to my friends.", + "The tyrant who rules my land will stop at nothing to see me killed.", + "I'm convinced of the significance of my destiny, and blind to my shortcomings and the risk of failure.", + "The people who knew me when I was young know my shameful secret, so I can never go home again.", + "I have a weakness for the vices of the city, especially hard drink.", + "Secretly, I believe that things would be better if I were a tyrant lording over the land.", + "I have trouble trusting in my allies.", + "I'll do anything to get my hands on something rare or priceless.", + "I'm quick to assume that someone is trying to cheat me.", + "No one must ever learn that I once stole money from guild coffers.", + "I'm never satisfied with what I have--I always want more.", + "I would kill to acquire a noble title.", + "I'm horribly jealous of anyone who outshines my handiwork. Everywhere I go, I'm surrounded by rivals.", + "Now that I've returned to the world, I enjoy its delights a little too much.", + "I harbor dark bloodthirsty thoughts that my isolation failed to quell.", + "I am dogmatic in my thoughts and philosophy.", + "I let my need to win arguments overshadow friendships and harmony.", + "I'd risk too much to uncover a lost bit of knowledge.", + "I like keeping secrets and won't share them with anyone.", + "I secretly believe that everyone is beneath me.", + "I hide a truly scandalous secret that could ruin my family forever.", + "I too often hear veiled insults and threats in every word addressed to me, and I'm quick to anger.", + "I have an insatiable desire for carnal pleasures.", + "In fact, the world does revolve around me.", + "By my words and actions, I often bring shame to my family.", + "I am too enamored of ale, wine, and other intoxicants.", + "There's no room for caution in a life lived to the fullest.", + "I remember every insult I've received and nurse a silent resentment toward anyone who's ever wronged me.", + "I am slow to trust members of other races", + "Violence is my answer to almost any challenge.", + "Don't expect me to save those who can't save themselves. It is natural the strong thrive and the weak perish.", + "I am easily distracted by the promise of information.", + "Most people scream and run when they see a demon. I stop and take notes on its anatomy.", + "Unlocking an ancient mystery is worth the price of a civilization.", + "I overlook obvious solutions in favor of complicated ones.", + "I speak without really thinking through my words, invariably insulting others.", + "I can't keep a secret to save my life, or anyone else's.", + "I follow orders, even if I think they're wrong.", + "I'll say anything to avoid having to do extra work.", + "Once someone questions my courage, I never back down no matter how dangerous the situation.", + "Once I start drinking, it's hard for me to stop.", + "I can't help but pocket loose coins and other trinkets I come across.", + "My pride will probably lead to my destruction", + "The monstrous enemy we faced in battle still leaves me quivering with fear.", + "I have little respect for anyone who is not a proven warrior.", + "I made a terrible mistake in battle that cost many lives--and I would do anything to keep that mistake secret.", + "My hatred of my enemies is blind and unreasoning.", + "I obey the law, even if the law causes misery.", + "I'd rather eat my armor than admit when I'm wrong.", + "If I'm outnumbered, I always run away from a fight.", + "Gold seems like a lot of money to me, and I'll do just about anything for more of it.", + "I will never fully trust anyone other than myself.", + "I'd rather kill someone in their sleep than fight fair.", + "It's not stealing if I need it more than someone else.", + "People who don't take care of themselves get what they deserve.", +] + +# source: https://www.fantasynamegenerators.com/ +goals = [ + "Find a more interesting life", + "Find a thrilling life", + "Abandon an ideology", + "Answer a call to adventure", + "Apologize", + "Appease a god", + "Appease the gods", + "Avenge a family member", + "Avenge a friend", + "Avoid a person", + "Avoid failure", + "Avoid responsibilities", + "Be a better person", + "Be a hero", + "Be a master in my field", + "Be able to eat", + "Be accepted by society", + "Be admired", + "Be amused", + "Be better than my rival", + "Be forgiven", + "Be in control", + "Be left alone", + "Be loved", + "Be redeemed", + "Be remembered", + "Be respected", + "Be self-sufficient", + "Be somebody else", + "Be strong", + "Be the best they can be", + "Become a leader", + "Become anonymous", + "Become famous", + "Become godly", + "Become powerful", + "Become the strongest", + "Belong somewhere", + "Better themselves", + "Break a habit", + "Break an addiction", + "Build my own home", + "Cause mayhem", + "Change a law", + "Change the future", + "Change the past", + "Clear a family member's name", + "Clear a friend's name", + "Clear my name", + "Complete a collection", + "Conquer my fear", + "Consume everything", + "Create a safe world", + "Create a utopia", + "Create a work of art", + "Cure a strange disease", + "Destroy corruption", + "Destroy evil", + "Discover a new planet", + "Do nothing", + "Do the impossible", + "Do the right thing", + "Eliminate evil", + "End a war", + "End suffering of all", + "End the conflict", + "End the suffering of a family member", + "End the suffering of a friend", + "Entertain others", + "Escape a bad situation", + "Escape death", + "Escape from my current life", + "Escape my destiny", + "Establish my own country", + "Experience a new culture", + "Experience something new", + "Explore the oceans", + "Explore the unexplored", + "Feel like they're worth something", + "Fight for my homeland", + "Find a cure", + "Find a dream job", + "Find a job", + "Find a legendary creature", + "Find a lost friend", + "Find a lost lover", + "Find a new creative outlet", + "Find a new home", + "Find a new passion", + "Find a purpose", + "Find a purpose in life", + "Find beauty", + "Find excitement", + "Find inspiration", + "Find love", + "Find out a secret", + "Find out the fate of a family member", + "Find out the fate of a friend", + "Find out my true identity", + "Find peace within", + "Find romance", + "Find my muse", + "Find true love", + "Fix a mistake", + "Follow orders", + "Forget my past", + "Forgive somebody", + "Free the animals", + "Fulfill a destiny", + "Gain the approval of somebody", + "Gain what somebody else has", + "Get away from my past", + "Get rich", + "Go on an adventure", + "Have a passionate relationship", + "Have fun", + "Have justice done", + "Have more and more", + "Have my work recognized", + "Have what others have", + "Have what they can never have", + "Have what they can't have", + "Lead a rebellion", + "Lift a curse", + "Live", + "Live a quiet life", + "Live dangerously", + "Live forever", + "Live in peace", + "Make a difference", + "Make a sacrifice for the greater good", + "Make a scientific breakthrough", + "Make friends", + "Make people smile", + "Make sure justice prevails", + "More power", + "Never be hurt again", + "No longer be afraid", + "No longer be bored", + "Overcome a death sentence", + "Overcome a disability", + "Overcome mockery from the past", + "Overcome stress", + "Overthrow the government", + "Protect a family member", + "Protect a friend", + "Protect nature", + "Protect the innocent", + "Protect the peace", + "Protect the planet", + "Protect my business", + "Protect my family", + "Protect my home", + "Protect my honor", + "Prove a theory", + "Prove them wrong", + "Reach perfection", + "Reach the promised lands", + "Reconcile with a person", + "Redeem somebody", + "Regain my honor", + "Remain hidden", + "Repay a debt", + "Repay a life debt", + "Resolve my guilt", + "Restart the world", + "Retrieve a lost item", + "Retrieve a stolen item", + "Reunite with a family member", + "Reunite with a lost friend", + "Revenge", + "Rid the world of evil", + "Rule the city", + "Run for the borders", + "Satisfy my curiosity", + "Save Christmas", + "Save a deity", + "See others suffer", + "See the gods pay for my crimes", + "See the world", + "Solve a mystery", + "Solve an ancient mystery", + "Spread chaos", + "Spread joy", + "Spread my ideology", + "Stand out from the crowd", + "Start a business", + "Start a family", + "Start a new world", + "Stop a criminal", + "Take a new direction in life", + "Thwart destiny", + "To fit in", + "Travel back into the past", + "Travel in space", + "Travel into the future", + "Uncover a secret plot", + "Win a competition", + "Win a game", + "Write a book" +] + +# source: http://ideonomy.mit.edu/essays/traits.html +personality = [ + 'Accessible', + 'Active', + 'Adaptable', + 'Admirable', + 'Adventurous', + 'Agreeable', + 'Alert', + 'Allocentric', + 'Amiable', + 'Anticipative', + 'Appreciative', + 'Articulate', + 'Aspiring', + 'Athletic', + 'Attractive', + 'Balanced', + 'Benevolent', + 'Brilliant', + 'Calm', + 'Capable', + 'Captivating', + 'Caring', + 'Challenging', + 'Charismatic', + 'Charming', + 'Cheerful', + 'Clean', + 'Clear-headed', + 'Clever', + 'Colorful', + 'Companionly', + 'Compassionate', + 'Conciliatory', + 'Confident', + 'Conscientious', + 'Considerate', + 'Constant', + 'Contemplative', + 'Cooperative', + 'Courageous', + 'Courteous', + 'Creative', + 'Cultured', + 'Curious', + 'Daring', + 'Debonair', + 'Decent', + 'Decisive', + 'Dedicated', + 'Deep', + 'Dignified', + 'Directed', + 'Disciplined', + 'Discreet', + 'Dramatic', + 'Dutiful', + 'Dynamic', + 'Earnest', + 'Ebullient', + 'Educated', + 'Efficient', + 'Elegant', + 'Eloquent', + 'Empathetic', + 'Energetic', + 'Enthusiastic', + 'Esthetic', + 'Exciting', + 'Extraordinary', + 'Fair', + 'Faithful', + 'Farsighted', + 'Felicific', + 'Firm', + 'Flexible', + 'Focused', + 'Forecful', + 'Forgiving', + 'Forthright', + 'Freethinking', + 'Friendly', + 'Fun-loving', + 'Gallant', + 'Generous', + 'Gentle', + 'Genuine', + 'Good-natured', + 'Gracious', + 'Hardworking', + 'Healthy', + 'Hearty', + 'Helpful', + 'Herioc', + 'High-minded', + 'Honest', + 'Honorable', + 'Humble', + 'Humorous', + 'Idealistic', + 'Imaginative', + 'Impressive', + 'Incisive', + 'Incorruptible', + 'Independent', + 'Individualistic', + 'Innovative', + 'Inoffensive', + 'Insightful', + 'Insouciant', + 'Intelligent', + 'Intuitive', + 'Invulnerable', + 'Kind', + 'Knowledge', + 'Leaderly', + 'Leisurely', + 'Liberal', + 'Logical', + 'Lovable', + 'Loyal', + 'Lyrical', + 'Magnanimous', + 'Many-sided', + 'Masculine', + 'Manly', + 'Mature', + 'Methodical', + 'Maticulous', + 'Moderate', + 'Modest', + 'Multi-leveled', + 'Neat', + 'Nonauthoritarian', + 'Objective', + 'Observant', + 'Open', + 'Optimistic', + 'Orderly', + 'Organized', + 'Original', + 'Painstaking', + 'Passionate', + 'Patient', + 'Patriotic', + 'Peaceful', + 'Perceptive', + 'Perfectionist', + 'Personable', + 'Persuasive', + 'Planful', + 'Playful', + 'Polished', + 'Popular', + 'Practical', + 'Precise', + 'Principled', + 'Profound', + 'Protean', + 'Protective', + 'Providential', + 'Prudent', + 'Punctual', + 'Pruposeful', + 'Rational', + 'Realistic', + 'Reflective', + 'Relaxed', + 'Reliable', + 'Resourceful', + 'Respectful', + 'Responsible', + 'Responsive', + 'Reverential', + 'Romantic', + 'Rustic', + 'Sage', + 'Sane', + 'Scholarly', + 'Scrupulous', + 'Secure', + 'Selfless', + 'Self-critical', + 'Self-defacing', + 'Self-denying', + 'Self-reliant', + 'Self-sufficent', + 'Sensitive', + 'Sentimental', + 'Seraphic', + 'Serious', + 'Sexy', + 'Sharing', + 'Shrewd', + 'Simple', + 'Skillful', + 'Sober', + 'Sociable', + 'Solid', + 'Sophisticated', + 'Spontaneous', + 'Sporting', + 'Stable', + 'Steadfast', + 'Steady', + 'Stoic', + 'Strong', + 'Studious', + 'Suave', + 'Subtle', + 'Sweet', + 'Sympathetic', + 'Systematic', + 'Tasteful', + 'Teacherly', + 'Thorough', + 'Tidy', + 'Tolerant', + 'Tractable', + 'Trusting', + 'Uncomplaining', + 'Understanding', + 'Undogmatic', + 'Unfoolable', + 'Upright', + 'Urbane', + 'Venturesome', + 'Vivacious', + 'Warm', + 'Well-bred', + 'Well-read', + 'Well-rounded', + 'Winning', + 'Wise', + 'Witty', + 'Youthful', + 'Absentminded', + 'Aggressive', + 'Ambitious', + 'Amusing', + 'Artful', + 'Ascetic', + 'Authoritarian', + 'Big-thinking', + 'Boyish', + 'Breezy', + 'Businesslike', + 'Busy', + 'Casual', + 'Crebral', + 'Chummy', + 'Circumspect', + 'Competitive', + 'Complex', + 'Confidential', + 'Conservative', + 'Contradictory', + 'Crisp', + 'Cute', + 'Deceptive', + 'Determined', + 'Dominating', + 'Dreamy', + 'Driving', + 'Droll', + 'Dry', + 'Earthy', + 'Effeminate', + 'Emotional', + 'Enigmatic', + 'Experimental', + 'Familial', + 'Folksy', + 'Formal', + 'Freewheeling', + 'Frugal', + 'Glamorous', + 'Guileless', + 'High-spirited', + 'Huried', + 'Hypnotic', + 'Iconoclastic', + 'Idiosyncratic', + 'Impassive', + 'Impersonal', + 'Impressionable', + 'Intense', + 'Invisible', + 'Irreligious', + 'Irreverent', + 'Maternal', + 'Mellow', + 'Modern', + 'Moralistic', + 'Mystical', + 'Neutral', + 'Noncommittal', + 'Noncompetitive', + 'Obedient', + 'Old-fashined', + 'Ordinary', + 'Outspoken', + 'Paternalistic', + 'Physical', + 'Placid', + 'Political', + 'Predictable', + 'Preoccupied', + 'Private', + 'Progressive', + 'Proud', + 'Pure', + 'Questioning', + 'Quiet', + 'Religious', + 'Reserved', + 'Restrained', + 'Retiring', + 'Sarcastic', + 'Self-conscious', + 'Sensual', + 'Skeptical', + 'Smooth', + 'Soft', + 'Solemn', + 'Solitary', + 'Stern', + 'Stoiid', + 'Strict', + 'Stubborn', + 'Stylish', + 'Subjective', + 'Surprising', + 'Soft', + 'Tough', + 'Unaggressive', + 'Unambitious', + 'Unceremonious', + 'Unchanging', + 'Undemanding', + 'Unfathomable', + 'Unhurried', + 'Uninhibited', + 'Unpatriotic', + 'Unpredicatable', + 'Unreligious', + 'Unsentimental', + 'Whimsical', + 'Abrasive', + 'Abrupt', + 'Agonizing', + 'Aimless', + 'Airy', + 'Aloof', + 'Amoral', + 'Angry', + 'Anxious', + 'Apathetic', + 'Arbitrary', + 'Argumentative', + 'Arrogantt', + 'Artificial', + 'Asocial', + 'Assertive', + 'Astigmatic', + 'Barbaric', + 'Bewildered', + 'Bizarre', + 'Bland', + 'Blunt', + 'Biosterous', + 'Brittle', + 'Brutal', + 'Calculating', + 'Callous', + 'Cantakerous', + 'Careless', + 'Cautious', + 'Charmless', + 'Childish', + 'Clumsy', + 'Coarse', + 'Cold', + 'Colorless', + 'Complacent', + 'Complaintive', + 'Compulsive', + 'Conceited', + 'Condemnatory', + 'Conformist', + 'Confused', + 'Contemptible', + 'Conventional', + 'Cowardly', + 'Crafty', + 'Crass', + 'Crazy', + 'Criminal', + 'Critical', + 'Crude', + 'Cruel', + 'Cynical', + 'Decadent', + 'Deceitful', + 'Delicate', + 'Demanding', + 'Dependent', + 'Desperate', + 'Destructive', + 'Devious', + 'Difficult', + 'Dirty', + 'Disconcerting', + 'Discontented', + 'Discouraging', + 'Discourteous', + 'Dishonest', + 'Disloyal', + 'Disobedient', + 'Disorderly', + 'Disorganized', + 'Disputatious', + 'Disrespectful', + 'Disruptive', + 'Dissolute', + 'Dissonant', + 'Distractible', + 'Disturbing', + 'Dogmatic', + 'Domineering', + 'Dull', + 'Easily Discouraged', + 'Egocentric', + 'Enervated', + 'Envious', + 'Erratic', + 'Escapist', + 'Excitable', + 'Expedient', + 'Extravagant', + 'Extreme', + 'Faithless', + 'False', + 'Fanatical', + 'Fanciful', + 'Fatalistic', + 'Fawning', + 'Fearful', + 'Fickle', + 'Fiery', + 'Fixed', + 'Flamboyant', + 'Foolish', + 'Forgetful', + 'Fraudulent', + 'Frightening', + 'Frivolous', + 'Gloomy', + 'Graceless', + 'Grand', + 'Greedy', + 'Grim', + 'Gullible', + 'Hateful', + 'Haughty', + 'Hedonistic', + 'Hesitant', + 'Hidebound', + 'High-handed', + 'Hostile', + 'Ignorant', + 'Imitative', + 'Impatient', + 'Impractical', + 'Imprudent', + 'Impulsive', + 'Inconsiderate', + 'Incurious', + 'Indecisive', + 'Indulgent', + 'Inert', + 'Inhibited', + 'Insecure', + 'Insensitive', + 'Insincere', + 'Insulting', + 'Intolerant', + 'Irascible', + 'Irrational', + 'Irresponsible', + 'Irritable', + 'Lazy', + 'Libidinous', + 'Loquacious', + 'Malicious', + 'Mannered', + 'Mannerless', + 'Mawkish', + 'Mealymouthed', + 'Mechanical', + 'Meddlesome', + 'Melancholic', + 'Meretricious', + 'Messy', + 'Miserable', + 'Miserly', + 'Misguided', + 'Mistaken', + 'Money-minded', + 'Monstrous', + 'Moody', + 'Morbid', + 'Muddle-headed', + 'Naive', + 'Narcissistic', + 'Narrow', + 'Narrow-minded', + 'Natty', + 'Negativistic', + 'Neglectful', + 'Neurotic', + 'Nihilistic', + 'Obnoxious', + 'Obsessive', + 'Obvious', + 'Odd', + 'Offhand', + 'One-dimensional', + 'One-sided', + 'Opinionated', + 'Opportunistic', + 'Oppressed', + 'Outrageous', + 'Overimaginative', + 'Paranoid', + 'Passive', + 'Pedantic', + 'Perverse', + 'Petty', + 'Pharissical', + 'Phlegmatic', + 'Plodding', + 'Pompous', + 'Possessive', + 'Power-hungry', + 'Predatory', + 'Prejudiced', + 'Presumptuous', + 'Pretentious', + 'Prim', + 'Procrastinating', + 'Profligate', + 'Provocative', + 'Pugnacious', + 'Puritanical', + 'Quirky', + 'Reactionary', + 'Reactive', + 'Regimental', + 'Regretful', + 'Repentant', + 'Repressed', + 'Resentful', + 'Ridiculous', + 'Rigid', + 'Ritualistic', + 'Rowdy', + 'Ruined', + 'Sadistic', + 'Sanctimonious', + 'Scheming', + 'Scornful', + 'Secretive', + 'Sedentary', + 'Selfish', + 'Self-indulgent', + 'Shallow', + 'Shortsighted', + 'Shy', + 'Silly', + 'Single-minded', + 'Sloppy', + 'Slow', + 'Sly', + 'Small-thinking', + 'Softheaded', + 'Sordid', + 'Steely', + 'Stiff', + 'Strong-willed', + 'Stupid', + 'Submissive', + 'Superficial', + 'Superstitious', + 'Suspicious', + 'Tactless', + 'Tasteless', + 'Tense', + 'Thievish', + 'Thoughtless', + 'Timid', + 'Transparent', + 'Treacherous', + 'Trendy', + 'Troublesome', + 'Unappreciative', + 'Uncaring', + 'Uncharitable', + 'Unconvincing', + 'Uncooperative', + 'Uncreative', + 'Uncritical', + 'Unctuous', + 'Undisciplined', + 'Unfriendly', + 'Ungrateful', + 'Unhealthy', + 'Unimaginative', + 'Unimpressive', + 'Unlovable', + 'Unpolished', + 'Unprincipled', + 'Unrealistic', + 'Unreflective', + 'Unreliable', + 'Unrestrained', + 'Unself-critical', + 'Unstable', + 'Vacuous', + 'Vague', + 'Venal', + 'Venomous', + 'Vindictive', + 'Vulnerable', + 'Weak', + 'Weak-willed', + 'Well-meaning', + 'Willful', + 'Wishful', + 'Zany', +] + +tail = [ + 'agile', + 'barbed', + 'bony', + 'broad', + 'cometary', + 'crooked', + 'enormous', + 'fat', + 'flat', + 'glorious', + 'graceful', + 'lengthy', + 'limber', + 'long', + 'monstrous', + 'muscular', + 'plump', + 'powerful', + 'ragged', + 'short', + 'sinewy', + 'sleek', + 'slender', + 'slim', + 'snake-like', + 'spiked', + 'stiff', + 'strong', + 'stubby', + 'supple', + 'tapered', + 'thick', + 'thin', + 'wide', +] + +horns = [ + 'little', + 'small', + 'spiraling', + 'wicked, spiraling', + 'curved, spiraling', + 'straight', + 'gnarled', + 'gracefully curved', + 'irregular', + 'long', + 'stubby', + 'sharp, spiral', + 'broad', + 'massive', + 'pointy', + 'magnificent', + 'backswept', + "ram's", + 'forward', + 'angled', + 'narrow', + 'needle-like', + 'slim', + 'graceful', + 'crooked', + 'conical,', + 'proud', + 'prodigious', + 'bony', + 'thick', + 'stout', + 'ringed', + 'ridged', + 'polished', + 'nubby', +] + +fangs = [ + 'mighty', + 'yellowed', + 'rotton', + 'small', + 'sharp', + 'curved', + 'gray', + 'fearsome', + 'needle-like', + 'nasty', + 'up-jutting', + 'long', + 'red-tipped', + 'canine', + 'vicious', + 'wide', + 'huge', + 'tiny', + 'filed', + 'jagged', + 'lower', + 'upper', + 'wicked', + 'prominent', + 'half-grown', + 'cruel', + 'keen white', + 'white', + 'gleaming', + 'well-developed', + 'razor-sharp', + 'dulled', + 'delicate', + 'dainty', + 'broken', + 'strong', + 'ugly', + 'slender', + 'stubby', + 'elegant', + 'savage', + 'worn', +] + +wings = [ + 'bat-like', + 'leathery', + 'broad', + 'powerful', + 'torn', + 'tattered', + 'strong', + 'underszed', + 'mighty', + 'imposing', + 'delicate', + 'scaly', + 'feathery', + 'magestic', +] diff --git a/npc/languages/__init__.py b/npc/languages/__init__.py new file mode 100644 index 0000000..71a872c --- /dev/null +++ b/npc/languages/__init__.py @@ -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', +] diff --git a/npc/languages/abyssal.py b/npc/languages/abyssal.py new file mode 100644 index 0000000..36fc732 --- /dev/null +++ b/npc/languages/abyssal.py @@ -0,0 +1,32 @@ +from npc.languages.base import BaseLanguage +import re + + +class Abyssal(BaseLanguage): + + vowels = ['a', 'e', 'i', 'o', 'u', 'î', 'ê', 'â', 'û', 'ô', 'ä', 'ö', 'ü', 'ä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 diff --git a/npc/languages/base.py b/npc/languages/base.py new file mode 100644 index 0000000..e2665ac --- /dev/null +++ b/npc/languages/base.py @@ -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()) diff --git a/npc/languages/celestial.py b/npc/languages/celestial.py new file mode 100644 index 0000000..1bdd477 --- /dev/null +++ b/npc/languages/celestial.py @@ -0,0 +1,32 @@ +from npc.languages.base import BaseLanguage +import re + + +class Celestial(BaseLanguage): + + vowels = ['a', 'e', 'i', 'o', 'u', 'î', 'ê', 'â', 'û', 'ô', 'ä', 'ö', 'ü', 'ä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 diff --git a/npc/languages/common.py b/npc/languages/common.py new file mode 100644 index 0000000..ec935e9 --- /dev/null +++ b/npc/languages/common.py @@ -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()) diff --git a/npc/languages/draconic.py b/npc/languages/draconic.py new file mode 100644 index 0000000..4dcbf27 --- /dev/null +++ b/npc/languages/draconic.py @@ -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 diff --git a/npc/languages/dwarvish.py b/npc/languages/dwarvish.py new file mode 100644 index 0000000..edd2dae --- /dev/null +++ b/npc/languages/dwarvish.py @@ -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) diff --git a/npc/languages/elven.py b/npc/languages/elven.py new file mode 100644 index 0000000..4fa4ad4 --- /dev/null +++ b/npc/languages/elven.py @@ -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 diff --git a/npc/languages/gnomish.py b/npc/languages/gnomish.py new file mode 100644 index 0000000..cb8803e --- /dev/null +++ b/npc/languages/gnomish.py @@ -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()) diff --git a/npc/languages/halfling.py b/npc/languages/halfling.py new file mode 100644 index 0000000..73f4134 --- /dev/null +++ b/npc/languages/halfling.py @@ -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()) diff --git a/npc/languages/infernal.py b/npc/languages/infernal.py new file mode 100644 index 0000000..0dcd366 --- /dev/null +++ b/npc/languages/infernal.py @@ -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() diff --git a/npc/languages/orcish.py b/npc/languages/orcish.py new file mode 100644 index 0000000..3f27580 --- /dev/null +++ b/npc/languages/orcish.py @@ -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'] diff --git a/npc/languages/undercommon.py b/npc/languages/undercommon.py new file mode 100644 index 0000000..768f8c2 --- /dev/null +++ b/npc/languages/undercommon.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dea2f10 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[tool.poetry] +name = "dnd_npcs" +version = "0.1.0" +description = "NPC tools for the telisar homebrew campaign setting" +authors = ["evilchili "] +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"