From 1208616ab3064e91c8a3a9ba0fadba3c7614f391 Mon Sep 17 00:00:00 2001 From: evilchili Date: Sat, 2 Dec 2023 18:24:26 -0800 Subject: [PATCH] # 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 --- npc/__init__.py | 26 +++ npc/{generator => ancestries}/__init__.py | 0 npc/{generator => ancestries}/dragon.py | 55 ++--- npc/ancestries/drow.py | 9 + npc/ancestries/dwarf.py | 9 + npc/ancestries/elf.py | 9 + npc/ancestries/halfling.py | 9 + npc/ancestries/halforc.py | 9 + npc/ancestries/human.py | 9 + npc/{generator => ancestries}/lizardfolk.py | 50 ++-- npc/ancestries/tiefling.py | 32 +++ npc/cli.py | 230 +++++++------------ npc/generator/drow.py | 15 -- npc/generator/dwarf.py | 12 - npc/generator/elf.py | 16 -- npc/generator/halfling.py | 10 - npc/generator/halforc.py | 12 - npc/generator/highelf.py | 16 -- npc/generator/hightiefling.py | 6 - npc/generator/human.py | 8 - npc/generator/tiefling.py | 39 ---- npc/languages/__init__.py | 27 --- npc/languages/abyssal.py | 32 --- npc/languages/base.py | 194 ---------------- npc/languages/celestial.py | 32 --- npc/languages/common.py | 94 -------- npc/languages/draconic.py | 67 ------ npc/languages/dwarvish.py | 42 ---- npc/languages/elven.py | 166 -------------- npc/languages/gnomish.py | 24 -- npc/languages/halfling.py | 53 ----- npc/languages/infernal.py | 108 --------- npc/languages/lizardfolk.py | 41 ---- npc/languages/orcish.py | 57 ----- npc/languages/undercommon.py | 122 ---------- npc/{generator => }/traits.py | 0 npc/{generator/base.py => types.py} | 242 +++++++++++--------- pyproject.toml | 13 +- 38 files changed, 376 insertions(+), 1519 deletions(-) rename npc/{generator => ancestries}/__init__.py (100%) rename npc/{generator => ancestries}/dragon.py (53%) create mode 100644 npc/ancestries/drow.py create mode 100644 npc/ancestries/dwarf.py create mode 100644 npc/ancestries/elf.py create mode 100644 npc/ancestries/halfling.py create mode 100644 npc/ancestries/halforc.py create mode 100644 npc/ancestries/human.py rename npc/{generator => ancestries}/lizardfolk.py (63%) create mode 100644 npc/ancestries/tiefling.py delete mode 100644 npc/generator/drow.py delete mode 100644 npc/generator/dwarf.py delete mode 100644 npc/generator/elf.py delete mode 100644 npc/generator/halfling.py delete mode 100644 npc/generator/halforc.py delete mode 100644 npc/generator/highelf.py delete mode 100644 npc/generator/hightiefling.py delete mode 100644 npc/generator/human.py delete mode 100644 npc/generator/tiefling.py delete mode 100644 npc/languages/__init__.py delete mode 100644 npc/languages/abyssal.py delete mode 100644 npc/languages/base.py delete mode 100644 npc/languages/celestial.py delete mode 100644 npc/languages/common.py delete mode 100644 npc/languages/draconic.py delete mode 100644 npc/languages/dwarvish.py delete mode 100644 npc/languages/elven.py delete mode 100644 npc/languages/gnomish.py delete mode 100644 npc/languages/halfling.py delete mode 100644 npc/languages/infernal.py delete mode 100644 npc/languages/lizardfolk.py delete mode 100644 npc/languages/orcish.py delete mode 100644 npc/languages/undercommon.py rename npc/{generator => }/traits.py (100%) rename npc/{generator/base.py => types.py} (66%) diff --git a/npc/__init__.py b/npc/__init__.py index e69de29..d9e6ca5 100644 --- a/npc/__init__.py +++ b/npc/__init__.py @@ -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 diff --git a/npc/generator/__init__.py b/npc/ancestries/__init__.py similarity index 100% rename from npc/generator/__init__.py rename to npc/ancestries/__init__.py diff --git a/npc/generator/dragon.py b/npc/ancestries/dragon.py similarity index 53% rename from npc/generator/dragon.py rename to npc/ancestries/dragon.py index 0b31fd9..166b637 100644 --- a/npc/generator/dragon.py +++ b/npc/ancestries/dragon.py @@ -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 diff --git a/npc/ancestries/drow.py b/npc/ancestries/drow.py new file mode 100644 index 0000000..e50132f --- /dev/null +++ b/npc/ancestries/drow.py @@ -0,0 +1,9 @@ +from language.languages import undercommon +from npc import types + + +class Drow(types.NPC): + language = undercommon + + +NPC = Drow diff --git a/npc/ancestries/dwarf.py b/npc/ancestries/dwarf.py new file mode 100644 index 0000000..730adb4 --- /dev/null +++ b/npc/ancestries/dwarf.py @@ -0,0 +1,9 @@ +from language.languages import dwarvish +from npc import types + + +class Dwarf(types.NPC): + language = dwarvish + + +NPC = Dwarf diff --git a/npc/ancestries/elf.py b/npc/ancestries/elf.py new file mode 100644 index 0000000..21a4704 --- /dev/null +++ b/npc/ancestries/elf.py @@ -0,0 +1,9 @@ +from language.languages import elvish +from npc import types + + +class Elf(types.NPC): + language = elvish + + +NPC = Elf diff --git a/npc/ancestries/halfling.py b/npc/ancestries/halfling.py new file mode 100644 index 0000000..33fb739 --- /dev/null +++ b/npc/ancestries/halfling.py @@ -0,0 +1,9 @@ +from language.languages import halfling +from npc import types + + +class Halfling(types.NPC): + language = halfling + + +NPC = Halfling diff --git a/npc/ancestries/halforc.py b/npc/ancestries/halforc.py new file mode 100644 index 0000000..3d87e0d --- /dev/null +++ b/npc/ancestries/halforc.py @@ -0,0 +1,9 @@ +from language.languages import orcish +from npc import types + + +class HalfOrc(types.NPC): + language = orcish + + +NPC = HalfOrc diff --git a/npc/ancestries/human.py b/npc/ancestries/human.py new file mode 100644 index 0000000..ac65f68 --- /dev/null +++ b/npc/ancestries/human.py @@ -0,0 +1,9 @@ +from language.languages import common +from npc import types + + +class Human(types.NPC): + language = common + + +NPC = Human diff --git a/npc/generator/lizardfolk.py b/npc/ancestries/lizardfolk.py similarity index 63% rename from npc/generator/lizardfolk.py rename to npc/ancestries/lizardfolk.py index 551b325..0c26d55 100644 --- a/npc/generator/lizardfolk.py +++ b/npc/ancestries/lizardfolk.py @@ -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 diff --git a/npc/ancestries/tiefling.py b/npc/ancestries/tiefling.py new file mode 100644 index 0000000..c158c31 --- /dev/null +++ b/npc/ancestries/tiefling.py @@ -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 diff --git a/npc/cli.py b/npc/cli.py index 9098516..a521c02 100644 --- a/npc/cli.py +++ b/npc/cli.py @@ -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__': diff --git a/npc/generator/drow.py b/npc/generator/drow.py deleted file mode 100644 index 26ba717..0000000 --- a/npc/generator/drow.py +++ /dev/null @@ -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() - ]) diff --git a/npc/generator/dwarf.py b/npc/generator/dwarf.py deleted file mode 100644 index 14b373f..0000000 --- a/npc/generator/dwarf.py +++ /dev/null @@ -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()]) diff --git a/npc/generator/elf.py b/npc/generator/elf.py deleted file mode 100644 index 30d3b7a..0000000 --- a/npc/generator/elf.py +++ /dev/null @@ -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() - ]) diff --git a/npc/generator/halfling.py b/npc/generator/halfling.py deleted file mode 100644 index c9cddcf..0000000 --- a/npc/generator/halfling.py +++ /dev/null @@ -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() diff --git a/npc/generator/halforc.py b/npc/generator/halforc.py deleted file mode 100644 index cb4c896..0000000 --- a/npc/generator/halforc.py +++ /dev/null @@ -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()]) diff --git a/npc/generator/highelf.py b/npc/generator/highelf.py deleted file mode 100644 index f9877c8..0000000 --- a/npc/generator/highelf.py +++ /dev/null @@ -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() - ]) diff --git a/npc/generator/hightiefling.py b/npc/generator/hightiefling.py deleted file mode 100644 index 83b3eff..0000000 --- a/npc/generator/hightiefling.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 9bdf93e..0000000 --- a/npc/generator/human.py +++ /dev/null @@ -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 diff --git a/npc/generator/tiefling.py b/npc/generator/tiefling.py deleted file mode 100644 index 0eb1436..0000000 --- a/npc/generator/tiefling.py +++ /dev/null @@ -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 diff --git a/npc/languages/__init__.py b/npc/languages/__init__.py deleted file mode 100644 index 71a872c..0000000 --- a/npc/languages/__init__.py +++ /dev/null @@ -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', -] diff --git a/npc/languages/abyssal.py b/npc/languages/abyssal.py deleted file mode 100644 index 36fc732..0000000 --- a/npc/languages/abyssal.py +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index e2665ac..0000000 --- a/npc/languages/base.py +++ /dev/null @@ -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()) diff --git a/npc/languages/celestial.py b/npc/languages/celestial.py deleted file mode 100644 index 1bdd477..0000000 --- a/npc/languages/celestial.py +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index ec935e9..0000000 --- a/npc/languages/common.py +++ /dev/null @@ -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()) diff --git a/npc/languages/draconic.py b/npc/languages/draconic.py deleted file mode 100644 index 4dcbf27..0000000 --- a/npc/languages/draconic.py +++ /dev/null @@ -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 diff --git a/npc/languages/dwarvish.py b/npc/languages/dwarvish.py deleted file mode 100644 index edd2dae..0000000 --- a/npc/languages/dwarvish.py +++ /dev/null @@ -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) diff --git a/npc/languages/elven.py b/npc/languages/elven.py deleted file mode 100644 index 4fa4ad4..0000000 --- a/npc/languages/elven.py +++ /dev/null @@ -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 diff --git a/npc/languages/gnomish.py b/npc/languages/gnomish.py deleted file mode 100644 index cb8803e..0000000 --- a/npc/languages/gnomish.py +++ /dev/null @@ -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()) diff --git a/npc/languages/halfling.py b/npc/languages/halfling.py deleted file mode 100644 index 73f4134..0000000 --- a/npc/languages/halfling.py +++ /dev/null @@ -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()) diff --git a/npc/languages/infernal.py b/npc/languages/infernal.py deleted file mode 100644 index 0dcd366..0000000 --- a/npc/languages/infernal.py +++ /dev/null @@ -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() diff --git a/npc/languages/lizardfolk.py b/npc/languages/lizardfolk.py deleted file mode 100644 index 309c67b..0000000 --- a/npc/languages/lizardfolk.py +++ /dev/null @@ -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), - ]), - ) diff --git a/npc/languages/orcish.py b/npc/languages/orcish.py deleted file mode 100644 index 3f27580..0000000 --- a/npc/languages/orcish.py +++ /dev/null @@ -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'] diff --git a/npc/languages/undercommon.py b/npc/languages/undercommon.py deleted file mode 100644 index 768f8c2..0000000 --- a/npc/languages/undercommon.py +++ /dev/null @@ -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 diff --git a/npc/generator/traits.py b/npc/traits.py similarity index 100% rename from npc/generator/traits.py rename to npc/traits.py diff --git a/npc/generator/base.py b/npc/types.py similarity index 66% rename from npc/generator/base.py rename to npc/types.py index 6fee7c3..41239ba 100644 --- a/npc/generator/base.py +++ b/npc/types.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 26c134f..177085a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"