# This is a combination of 2 commits.

# This is the 1st commit message:

Rewrite!

This is a full rewrite of the npc generator code to use the
newly-refactored dnd-name-generator code

# This is the commit message #2:

bugfixen
This commit is contained in:
evilchili 2023-12-02 18:24:26 -08:00
parent 2e5b5556a3
commit 1208616ab3
38 changed files with 376 additions and 1519 deletions

View File

@ -0,0 +1,26 @@
import importlib
import os
import pkgutil
import sys
from types import ModuleType
ancestry_pack = None
supported_ancestries = None
def _import_submodules(module):
pkgs = pkgutil.iter_modules(module.__path__)
for loader, module_name, is_pkg in pkgs:
yield importlib.import_module(f"{module.__name__}.{module_name}")
def load_ancestry_pack(module_name: str = "") -> ModuleType:
if not module_name:
module_name = os.getenv("NPC_ANCESTRY_PACK", "npc.ancestries")
ancestry_pack = importlib.import_module(module_name)
_import_submodules(ancestry_pack)
supported_ancestries = dict(
(module.__name__.split(".")[-1], module) for module in list(_import_submodules(sys.modules[module_name]))
)
return ancestry_pack, supported_ancestries

View File

@ -1,45 +1,29 @@
from npc.languages import draconic
from npc.generator.base import BaseNPC, a_or_an
from npc.generator import traits
from functools import cached_property
from npc import types
import textwrap
import random
from language.languages import draconic
class NPC(BaseNPC):
ancestry = 'Dragon'
language = draconic.Dragon()
class Dragon(types.NPC):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
language = draconic
is_noble = True
self._tail = None
self._horns = None
self._fangs = None
self._wings = None
has_tail = True
has_horns = True
has_fangs = True
has_wings = True
@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
@cached_property
def age(self) -> str:
return random.choice(['wyrmling', 'young', 'adult', 'ancient'])
@property
def pronouns(self):
if not self._pronouns:
self._pronouns = 'they/they'
return self._pronouns
@cached_property
def pronouns(self) -> str:
return 'they/they'
@property
def skin_color(self):
@ -68,8 +52,8 @@ class NPC(BaseNPC):
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}."
f"{self.name} ({self.pronouns}) is {types.a_or_an(self.age)} {self.age} {self.skin_color} "
f"{self.ancestry.lower()} with {types.a_or_an(self.nose)} {self.nose} snout, {self.body} body and {trait}."
)
@property
@ -99,3 +83,6 @@ Goal: {self.goal}
Whereabouts: {self.whereabouts}
"""
NPC = Dragon

9
npc/ancestries/drow.py Normal file
View File

@ -0,0 +1,9 @@
from language.languages import undercommon
from npc import types
class Drow(types.NPC):
language = undercommon
NPC = Drow

9
npc/ancestries/dwarf.py Normal file
View File

@ -0,0 +1,9 @@
from language.languages import dwarvish
from npc import types
class Dwarf(types.NPC):
language = dwarvish
NPC = Dwarf

9
npc/ancestries/elf.py Normal file
View File

@ -0,0 +1,9 @@
from language.languages import elvish
from npc import types
class Elf(types.NPC):
language = elvish
NPC = Elf

View File

@ -0,0 +1,9 @@
from language.languages import halfling
from npc import types
class Halfling(types.NPC):
language = halfling
NPC = Halfling

View File

@ -0,0 +1,9 @@
from language.languages import orcish
from npc import types
class HalfOrc(types.NPC):
language = orcish
NPC = HalfOrc

9
npc/ancestries/human.py Normal file
View File

@ -0,0 +1,9 @@
from language.languages import common
from npc import types
class Human(types.NPC):
language = common
NPC = Human

View File

@ -1,22 +1,17 @@
from npc.languages import lizardfolk
from npc.generator.base import BaseNPC, a_or_an
from functools import cached_property
import textwrap
import random
from language.languages import lizardfolk
from npc import types
class NPC(BaseNPC):
ancestry = 'Lizardfolk'
language = lizardfolk.Lizardfolk()
class Lizardfolk(types.NPC):
language = lizardfolk
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._tail = None
self._horns = None
self._fangs = None
self._frills = None
has_tail = True
has_horns = True
has_fangs = True
@property
def age(self):
@ -38,19 +33,17 @@ class NPC(BaseNPC):
self._tail = 'no'
return self._tail
@property
@cached_property
def frills(self):
if not self._frills:
if self.age in ('adult', 'ancient'):
self._frills = random.choice([
'orange',
'red',
'yellow',
'green',
'blue',
'silvery',
])
return self._frills
if self.age in ('adult', 'ancient'):
return random.choice([
'orange',
'red',
'yellow',
'green',
'blue',
'silvery',
])
@property
def skin_color(self):
@ -76,8 +69,8 @@ class NPC(BaseNPC):
self.facial_structure,
])
return (
f"{self.full_name} ({self.pronouns}) is {a_or_an(self.age)} {self.age}, {self.skin_color}-scaled "
f"{self.ancestry.lower()} with {a_or_an(self.nose)} {self.nose} snout, {self.body} body and {trait}."
f"{self.fullname} ({self.pronouns}) is {types.a_or_an(self.age)} {self.age}, {self.skin_color}-scaled "
f"{self.ancestry.lower()} with {types.a_or_an(self.nose)} {self.nose} snout, {self.body} body and {trait}."
)
@property
@ -106,3 +99,6 @@ Goal: {self.goal}
Whereabouts: {self.whereabouts}
"""
NPC = Lizardfolk

View File

@ -0,0 +1,32 @@
import random
from language.languages import infernal
from npc import types
class Tiefling(types.NPC):
language = infernal
has_tail = True
has_horns = True
has_fangs = True
@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
NPC = Tiefling

View File

@ -1,172 +1,104 @@
from npc.generator.base import generate_npc, npc_type
from npc import languages
from npc import load_ancestry_pack
import random
import typer
import logging
import os
from enum import Enum
from typing import Union
import typer
from rich import print
from rich.logging import RichHandler
class Ancestry(str, Enum):
dragon = 'dragon'
drow = 'drow'
dwarf = 'dwarf'
elf = 'elf'
halfling = 'halfling'
halforc = 'halforc'
highelf = 'highelf'
highttiefling = 'hightiefling'
human = 'human'
tiefling = 'tiefling'
lizardfolk = 'lizardfolk'
class Language(str, Enum):
abyssal = 'abyssal'
celestial = 'celestial'
common = 'commmon'
draconic = 'draconic'
dwarvish = 'dwarvish'
elven = 'elven'
gnomish = 'gnomish'
halfling = 'halfing'
infernal = 'infernal'
orcish = 'orcish'
undercommon = 'undercommon'
lizardfolk = 'lizardfolk'
from language import load_language_pack
app = typer.Typer()
app_state = {}
language_pack, supported_languages = load_language_pack()
SupportedLanguages = Enum("SupportedLanguages", ((k, k) for k in supported_languages.keys()))
ancestry_pack, supported_ancestries = load_ancestry_pack()
SupportedAncestries = Enum("SupportedAncestries", ((k, k) for k in supported_ancestries.keys()))
def get_npc(**kwargs):
return app_state['ancestry'].NPC(language=app_state['language'], **kwargs)
@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,
ancestry: SupportedAncestries = typer.Option(
default="human",
help="The ancestry to use."
),
language: Union[SupportedLanguages, None] = typer.Option(
default=None,
help="The language to use. Will be derived from ancestry if not specified."
),
verbose: bool = typer.Option(
default=False,
help="If True, print verbose character descriptions."
)
):
app_state["ancestry"] = supported_ancestries[ancestry.name]
if language:
app_state["language"] = supported_languages[language.name]
else:
app_state["language"] = None
debug = os.getenv("NPC_DEBUG", None)
logging.basicConfig(
format="%(name)s %(message)s",
level=logging.DEBUG if debug else logging.INFO,
handlers=[RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])],
)
logging.debug(f"Loaded ancestry pack {ancestry_pack}.")
logging.debug(f"Loaded language pack {language_pack}.")
app_state['verbose'] = verbose
if ctx.invoked_subcommand is None:
return commoner()
@app.command()
def npc(
ancestry: Ancestry = typer.Option(
None,
help='Derive NPC characteristics from a specific ancestry. Randomized if not specified.',
),
name: str = typer.Option(
None,
help='Specify the NPC name. Randomized names are derived from ancestry',
),
pronouns: str = typer.Option(
None,
help='Specify the NPC pronouns.',
),
title: str = typer.Option(
None,
help='Specify the NPC title.',
),
nickname: str = typer.Option(
None,
help='Specify the NPC nickname.',
),
whereabouts: str = typer.Option(
None,
help='Specify the NPC whereabouts.',
),
STR: str = typer.Option(
None,
help='Specify the NPC strength score.',
),
DEX: str = typer.Option(
None,
help='Specify the NPC dexterity score.',
),
CON: str = typer.Option(
None,
help='Specify the NPC constitution score.',
),
INT: str = typer.Option(
None,
help='Specify the NPC intelligence score.',
),
WIS: str = typer.Option(
None,
help='Specify the NPC wisdom score.',
),
CHA: str = typer.Option(
None,
help='Specify the NPC charisma score.',
),
randomize: bool = typer.Option(
False,
help='If True, randomize default stat scores. If False, all stats are 10.'
),
) -> None:
def commoner() -> None:
"""
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)
char = get_npc()
if app_state['verbose']:
print(char.character_sheet)
else:
print(char)
@app.command()
def names(ancestry: Ancestry = typer.Option(
None,
help='Derive NPC characteristics from a specific ancestry. Randomized if not specified.',
),
count: int = typer.Option(
1,
help='How many names to generate.'
),
) -> None:
for _ in range(int(count)):
print(npc_type(ancestry)().full_name)
def adventurer() -> None:
"""
Generate a basic NPC.
"""
char = get_npc(randomize=True)
if app_state['verbose']:
print(char.character_sheet)
else:
print(char)
@app.command()
def text(
language: Language = typer.Option(
'common',
help='The language for which to generate text.',
),
count: int = typer.Argument(
50,
help='How many words to generate.'
),
) -> None:
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(count))]:
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}.")
def noble() -> None:
"""
Generate a basic NPC.
"""
char = get_npc(randomize=True, noble=True)
if app_state['verbose']:
print(char.character_sheet)
else:
print(char)
if __name__ == '__main__':

View File

@ -1,15 +0,0 @@
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()
])

View File

@ -1,12 +0,0 @@
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()])

View File

@ -1,16 +0,0 @@
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()
])

View File

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

View File

@ -1,12 +0,0 @@
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()])

View File

@ -1,16 +0,0 @@
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

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

View File

@ -1,8 +0,0 @@
from language.languages.common import common_name
from npc.generator.base import BaseNPC
class NPC(BaseNPC):
ancestry = 'Human'
name_generator = common_name

View File

@ -1,39 +0,0 @@
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

View File

@ -1,27 +0,0 @@
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',
]

View File

@ -1,32 +0,0 @@
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

View File

@ -1,194 +0,0 @@
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

@ -1,32 +0,0 @@
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

View File

@ -1,94 +0,0 @@
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())

View File

@ -1,67 +0,0 @@
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

View File

@ -1,42 +0,0 @@
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)

View File

@ -1,166 +0,0 @@
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

View File

@ -1,24 +0,0 @@
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())

View File

@ -1,53 +0,0 @@
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())

View File

@ -1,108 +0,0 @@
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()

View File

@ -1,41 +0,0 @@
from npc.languages.base import BaseLanguage
import random
class Lizardfolk(BaseLanguage):
vowels = []
consonants = []
affixes = []
syllable_template = ()
syllable_weights = []
family = [
'sweet', 'floral', 'fruity', 'sour', 'fermented', 'green', 'vegetal', 'old',
'roasted', 'spiced', 'nutty', 'cocoa', 'pepper', 'pungent', 'burnt', 'carmelized',
'raw', 'rotting', 'dead', 'young',
]
scents = [
'honey', 'caramel', 'maple syrup', 'molasses', 'dark chocolate', 'chocolate', 'almond',
'hazelnut', 'peanut', 'clove', 'cinnamon', 'nutmeg', 'anise', 'malt', 'grain', 'roast',
'smoke', 'ash', 'acrid', 'rubber', 'skunk', 'petroleum', 'medicine', 'salt', 'bitter',
'phrenolic', 'meat', 'broth', 'animal', 'musty', 'earth', 'mould', 'damp', 'wood', 'paper',
'cardboard', 'stale', 'herb', 'hay', 'grass', 'peapod', 'whisky', 'wine', 'malic',
'citric', 'isovaleric', 'butyric', 'acetic', 'lime', 'lemon',
'orange', 'grapefruit', 'pear', 'peach', 'apple', 'grape', 'pineapple', 'pomegranate',
'cherry', 'coconut', 'prune', 'raisin', 'strawberry', 'blueberry', 'raspberry',
'blackberry', 'jasmine', 'rose', 'camomile', 'tobacco',
]
nicknames = []
def person(self):
return(
random.choice(self.family),
'-'.join([
random.choice(self.scents),
random.choice(self.scents),
]),
)

View File

@ -1,57 +0,0 @@
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

@ -1,122 +0,0 @@
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

View File

@ -1,39 +1,125 @@
from importlib import import_module
from npc.generator import traits
import os
import glob
from npc import traits
import random
import dice
import textwrap
import logging
from typing import Union
AVAILABLE_NPC_TYPES = {}
from language.types import Name, Language
def a_or_an(s):
return 'an' if s[0] in 'aeiouh' else 'a'
class BaseNPC:
class StatBlock:
def __init__(
self,
STR: int = 10,
DEX: int = 10,
CON: int = 10,
INT: int = 10,
WIS: int = 10,
CHA: int = 10,
HP: int = 10,
AC: int = 10,
speed: int = 30,
passive_perception: int = 10,
passive_investigation: int = 10,
):
self.STR = STR
self.DEX = DEX
self.CON = CON
self.INT = INT
self.WIS = WIS
self.CHA = CHA
self.HP = HP
self.AC = AC
self.speed = speed
self.passive_perception = passive_perception
self.passive_investigation = passive_investigation
def randomize(self):
stats = [15, 14, 13, 12, 10, 8]
random.shuffle(stats)
if random.random() < 0.3:
i = random.choice(range(len(stats)))
stats[i] += (random.choice([-1, 1]) * random.randint(1, 3))
(self.STR, self.DEX, self.CON, self.INT, self.WIS, self.CHA) = stats
self.HP = str(sum(dice.roll('2d8')) + 2) + ' (2d8+2)'
def __str__(self):
return textwrap.dedent(f"""
AC {self.AC}
HP {self.HP}
STR {self.STR}
DEX {self.DEX}
CON {self.CON}
INT {self.INT}
WIS {self.WIS}
CHA {self.CHA}
Speed: {self.speed}
Passive Perception: {self.passive_perception}
Passive Investigation: {self.passive_perception}
""")
class NPC:
"""
The base class for NPCs.
Return a randomized NPC. Any supplied keyword parameters will override
generated, randomized 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.
"""
# define this on your subclass
# Define this as a language module from language.supported_languages.values()
language = None
_names = []
# appearance
has_eyes = True
has_hair = True
has_face = True
has_body = True
has_nose = True
has_lips = True
has_teeth = True
has_skin_tone = True
has_skin_color = True
has_facial_hair = True
has_facial_structure = True
has_eyebrows = True
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):
has_age = True
has_voice = True
has_tail = False
has_horns = False
has_fangs = False
has_wings = False
def __init__(
self,
name: Union[Name, None] = None,
pronouns: Union[str, None] = None,
whereabouts: str = "Unknown",
stats: StatBlock = StatBlock(),
noble: bool = False,
randomize: bool = False,
language: Union[Language, None] = None,
):
# identity
self._names = []
self._pronouns = pronouns
self._nickname = nickname
self._title = title
self._name = name
self._is_noble = noble
self._whereabouts = whereabouts
self._stats = stats
self._pronouns = pronouns
# appearance
self._eyes = None
@ -50,53 +136,40 @@ class BaseNPC:
self._eyebrows = None
self._age = None
self._voice = None
self._tail = False
self._horns = False
self._fangs = False
self._wings = False
self._tail = None
self._horns = None
self._fangs = None
self._wings = None
# character
self._flaw = None
self._goal = None
self._personality = None
stats = (10, 10, 10, 10, 10, 10)
if language:
self.language = language
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
self.stats.randomize()
@property
def HP(self):
if not self._HP:
self._HP = str(sum(dice.roll('2d8')) + 2) + ' (2d8+2)'
return self._HP
def ancestry(self) -> str:
return self.__class__.__name__
@property
def names(self):
if not self._names:
self._names = next(self.name_generator.name(1))
return self._names
def is_noble(self) -> bool:
return self._is_noble
@property
def full_name(self):
return self.names.fullname
def name(self):
if not self._name:
generator = getattr(
self.language,
'NobleName' if self.is_noble else 'Name'
)
self._name = generator.name()[0]
logging.debug(self._name)
return self._name['fullname']
@property
def pronouns(self):
@ -250,10 +323,14 @@ class BaseNPC:
self._voice = random.choice(traits.voice)
return self._voice
@property
def stats(self):
return self._stats
@property
def description(self):
desc = (
f"{self.full_name} ({self.pronouns}) is {a_or_an(self.age)} {self.age}, {self.body} "
f"{self.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
@ -267,9 +344,9 @@ class BaseNPC:
self.facial_structure if self.facial_structure else None,
])
desc = desc + ' ' + f"Their face is {self.face}, with {trait}."
if self.tail:
if self.has_tail:
desc = desc + f" Their tail is {self.tail}."
if self.horns:
if self.has_horns:
desc = desc + f" Their horns are {self.horns}."
return desc
@ -295,14 +372,7 @@ 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}
{textwrap.indent(str(self.stats), prefix=' ')}
Details:
@ -315,52 +385,4 @@ 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
)
return self.description

View File

@ -11,14 +11,15 @@ packages = [
[tool.poetry.dependencies]
python = "^3.10"
typer = "latest"
rich = "latest"
dice = "latest"
rich = "^13.7.0"
typer = "^0.9.0"
dice = "^4.0.0"
dnd-languages = { git = "https://github.com/evilchili/dnd-languages", branch = 'mainline' }
#dnd-name-generator = { git = "https://github.com/evilchili/dnd-name-generator", branch='main' }
dnd-name-generator = {path = "../dnd-name-generator/dist/dnd_name_generator-1.0-py3-none-any.whl"}
[tool.poetry.dev-dependencies]
pytest = "latest"
[tool.poetry.group.dev.dependencies]
pytest = "^7.4.3"
black = "^23.3.0"
isort = "^5.12.0"
pyproject-autoflake = "^1.0.2"