355 lines
13 KiB
Python
355 lines
13 KiB
Python
"""
|
|
Random Name Generator
|
|
|
|
Contribution by InspectorCaracal (2022)
|
|
|
|
A module for generating random names, both real-world and fantasy. Real-world
|
|
names can be generated either as first (personal) names, family (last) names, or
|
|
full names (first, optional middles, and last). The name data is from [Behind the Name](https://www.behindthename.com/)
|
|
and used under the [CC BY-SA 4.0 license](https://creativecommons.org/licenses/by-sa/4.0/).
|
|
|
|
Fantasy names are generated from basic phonetic rules, using CVC syllable syntax.
|
|
|
|
Both real-world and fantasy name generation can be extended to include additional
|
|
information via your game's `settings.py`
|
|
|
|
|
|
Available Methods:
|
|
|
|
first_name - Selects a random a first (personal) name from the name lists.
|
|
last_name - Selects a random last (family) name from the name lists.
|
|
full_name - Generates a randomized full name, optionally including middle names, by selecting first/last names from the name lists.
|
|
fantasy_name - Generates a completely new made-up name based on phonetic rules.
|
|
|
|
Method examples:
|
|
|
|
>>> namegen.first_name(num=5)
|
|
['Genesis', 'Tali', 'Budur', 'Dominykas', 'Kamau']
|
|
|
|
>>> namegen.full_name(parts=3, surname_first=True)
|
|
'Ó Muircheartach Torunn Dyson'
|
|
>>> namegen.full_name(gender='f')
|
|
'Wikolia Ó Deasmhumhnaigh'
|
|
|
|
>>> namegen.fantasy_name(num=3, style="fluid")
|
|
['Aewalisash', 'Ayi', 'Iaa']
|
|
|
|
|
|
Available Settings (define these in your `settings.py`)
|
|
|
|
NAMEGEN_FIRST_NAMES - Option to add a new list of first (personal) names.
|
|
NAMEGEN_LAST_NAMES - Option to add a new list of last (family) names.
|
|
NAMEGEN_REPLACE_LISTS - Set to True if you want to use ONLY your name lists and not the ones that come with the contrib.
|
|
NAMEGEN_FANTASY_RULES - Option to add new fantasy-name style rules.
|
|
Must be a dictionary that includes "syllable", "consonants", "vowels", and "length" - see the example.
|
|
"start" and "end" keys are optional.
|
|
|
|
Settings examples:
|
|
|
|
NAMEGEN_FIRST_NAMES = [
|
|
("Evennia", 'mf'),
|
|
("Green Tea", 'f'),
|
|
]
|
|
|
|
NAMEGEN_LAST_NAMES = [ "Beeblebrox", "Son of Odin" ]
|
|
|
|
NAMEGEN_FANTASY_RULES = {
|
|
"example_style": {
|
|
"syllable": "(C)VC",
|
|
"consonants": [ 'z','z','ph','sh','r','n' ],
|
|
"start": ['m'],
|
|
"end": ['x','n'],
|
|
"vowels": [ "e","e","e","a","i","i","u","o", ],
|
|
"length": (2,4),
|
|
}
|
|
}
|
|
|
|
"""
|
|
|
|
import random
|
|
import re
|
|
from os import path
|
|
from django.conf import settings
|
|
|
|
from evennia.utils.utils import is_iter
|
|
|
|
# Load name data from Behind the Name lists
|
|
dirpath = path.dirname(path.abspath(__file__))
|
|
_FIRSTNAME_LIST = []
|
|
with open(path.join(dirpath, "btn_givennames.txt"),'r', encoding='utf-8') as file:
|
|
_FIRSTNAME_LIST = [ line.strip().rsplit(" ") for line in file if line and not line.startswith("#") ]
|
|
|
|
_SURNAME_LIST = []
|
|
with open(path.join(dirpath, "btn_surnames.txt"),'r', encoding='utf-8') as file:
|
|
_SURNAME_LIST = [ line.strip() for line in file if line and not line.startswith("#") ]
|
|
|
|
_REQUIRED_KEYS = { "syllable", "consonants", "vowels", "length" }
|
|
# Define phoneme structure for built-in fantasy name generators.
|
|
_FANTASY_NAME_STRUCTURES = {
|
|
"harsh": {
|
|
"syllable": "CV(C)",
|
|
"consonants": [ "k", "k", "k", "z", "zh", "g", "v", "t", "th", "w", "n", "d", "d", ],
|
|
"start": ["dh", "kh", "kh", "kh", "vh", ],
|
|
"end": ["n", "x", ],
|
|
"vowels": [ "o", "o", "o", "a", "y", "u", "u", "u", "ä", "ö", "e", "i", "i", ],
|
|
"length": (1,3),
|
|
},
|
|
"fluid": {
|
|
"syllable": "V(C)",
|
|
"consonants": [ 'r','r','l','l','l','l','s','s','s','sh','m','n','n','f','v','w','th' ],
|
|
"start": [],
|
|
"end": [],
|
|
"vowels": [ "a","a","a","a","a","e","i","i","i","y","u","o", ],
|
|
"length": (3,5),
|
|
},
|
|
"alien": {
|
|
"syllable": "C(C(V))(')(C)",
|
|
"consonants": [ 'q','q','x','z','v','w','k','h','b' ],
|
|
"start": ['x',],
|
|
"end": [],
|
|
"vowels": [ 'y','w','o','y' ],
|
|
"length": (1,5),
|
|
},
|
|
|
|
}
|
|
|
|
_RE_DOUBLES = re.compile(r'(\w)\1{2,}')
|
|
|
|
# Load in optional settings
|
|
|
|
custom_first_names = settings.NAMEGEN_FIRST_NAMES if hasattr(settings, "NAMEGEN_FIRST_NAMES") else []
|
|
custom_last_names = settings.NAMEGEN_LAST_NAMES if hasattr(settings, "NAMEGEN_LAST_NAMES") else []
|
|
|
|
if hasattr(settings, "NAMEGEN_FANTASY_RULES"):
|
|
_FANTASY_NAME_STRUCTURES |= settings.NAMEGEN_FANTASY_RULES
|
|
|
|
if hasattr(settings, "NAMEGEN_REPLACE_LISTS") and settings.NAMEGEN_REPLACE_LISTS:
|
|
_FIRSTNAME_LIST = custom_first_names or _FIRSTNAME_LIST
|
|
_SURNAME_LIST = custom_last_names or _SURNAME_LIST
|
|
|
|
else:
|
|
_FIRSTNAME_LIST += custom_first_names
|
|
_SURNAME_LIST += custom_last_names
|
|
|
|
|
|
|
|
def fantasy_name(num=1, style="harsh", return_list=False):
|
|
"""
|
|
Generate made-up names in one of a number of "styles".
|
|
|
|
Keyword args:
|
|
num (int) - How many names to return.
|
|
style (string) - The "style" of name. This references an existing algorithm.
|
|
return_list (bool) - Whether to always return a list. `False` by default,
|
|
which returns a string if there is only one value and a list if more.
|
|
"""
|
|
|
|
def _validate(style_name):
|
|
if style_name not in _FANTASY_NAME_STRUCTURES:
|
|
raise ValueError(f"Invalid style name: '{style_name}'. Available style names: {' '.join(_FANTASY_NAME_STRUCTURES.keys())}")
|
|
style_dict = _FANTASY_NAME_STRUCTURES[style_name]
|
|
|
|
if type(style_dict) is not dict:
|
|
raise ValueError(f"Style {style_name} must be a dictionary.")
|
|
|
|
keys = set(style_dict.keys())
|
|
missing_keys = _REQUIRED_KEYS - keys
|
|
if len(missing_keys):
|
|
raise KeyError(f"Style dictionary {style_name} is missing required keys: {' '.join(missing_keys)}")
|
|
|
|
if not (type(style_dict['consonants']) is list and type(style_dict['vowels']) is list):
|
|
raise TypeError(f"'consonants' and 'vowels' for style {style_name} must be lists.")
|
|
|
|
if not (is_iter(style_dict['length']) and len(style_dict['length']) == 2):
|
|
raise ValueError(f"'length' key for {style_name} must have a minimum and maximum number of syllables.")
|
|
|
|
return style_dict
|
|
|
|
# validate num first
|
|
num = int(num)
|
|
if num < 1:
|
|
raise ValueError("Number of names to generate must be positive.")
|
|
|
|
style_dict = _validate(style)
|
|
|
|
syllable = []
|
|
weight = 8
|
|
# parse out the syllable structure with weights
|
|
for key in style_dict["syllable"]:
|
|
# parentheses mean optional - allow nested parens
|
|
if key == "(":
|
|
weight = weight/2
|
|
elif key == ")":
|
|
weight = weight*2
|
|
else:
|
|
if key == "C":
|
|
sound_type = "consonants"
|
|
elif key == "V":
|
|
sound_type = "vowels"
|
|
else:
|
|
sound_type = key
|
|
# append the sound type and weight
|
|
syllable.append( (sound_type, int(weight)) )
|
|
|
|
name_list = []
|
|
|
|
# time to generate a name!
|
|
for n in range(num):
|
|
# build a list of syllables
|
|
length = random.randint(*style_dict['length'])
|
|
name = ""
|
|
for i in range(length):
|
|
# build the syllable itself
|
|
syll = ""
|
|
for sound, weight in syllable:
|
|
# random chance to skip this key; lower weights mean less likely
|
|
if random.randint(0,8) > weight:
|
|
continue
|
|
|
|
if sound not in style_dict:
|
|
# extra character, like apostrophes
|
|
syll += sound
|
|
continue
|
|
|
|
# get a random sound from the sound list
|
|
choices = list(style_dict[sound])
|
|
|
|
if sound == "consonants":
|
|
# if it's a starting consonant, add starting-sounds to the options
|
|
if not len(syll):
|
|
choices += style_dict.get('start',[])
|
|
# if it's an ending consonant, add ending-sounds to the options
|
|
elif i+1 == length:
|
|
choices += style_dict.get('end',[])
|
|
|
|
syll += random.choice(choices)
|
|
|
|
name += syll
|
|
|
|
# condense repeating letters down to a maximum of 2
|
|
name = _RE_DOUBLES.sub(lambda m: m.group(1)*2, name)
|
|
# capitalize the first letter
|
|
name = name[0].upper() + name[1:] if len(name) > 1 else name.upper()
|
|
name_list.append(name)
|
|
|
|
if len(name_list) == 1 and not return_list:
|
|
return name_list[0]
|
|
return name_list
|
|
|
|
def first_name(num=1, gender=None, return_list=False, ):
|
|
"""
|
|
Generate first names, also known as personal names.
|
|
|
|
Keyword args:
|
|
num (int) - How many names to return.
|
|
gender (str) - Restrict names by gender association. `None` by default, which selects from
|
|
all possible names. Set to "m" for masculine, "f" for feminine, "mf" for androgynous
|
|
return_list (bool) - Whether to always return a list. `False` by default,
|
|
which returns a string if there is only one value and a list if more.
|
|
"""
|
|
# validate num first
|
|
num = int(num)
|
|
if num < 1:
|
|
raise ValueError("Number of names to generate must be positive.")
|
|
|
|
if gender:
|
|
# filter the options by gender
|
|
name_options = [ name_data[0] for name_data in _FIRSTNAME_LIST if all([gender_key in gender for gender_key in name_data[1]])]
|
|
if not len(name_options):
|
|
raise ValueError(f"Invalid gender '{gender}'.")
|
|
else:
|
|
name_options = [ name_data[0] for name_data in _FIRSTNAME_LIST ]
|
|
|
|
# take a random selection of `num` names, without repeats
|
|
results = random.sample(name_options,num)
|
|
|
|
if len(results) == 1 and not return_list:
|
|
# return single value as a string
|
|
return results[0]
|
|
|
|
return results
|
|
|
|
|
|
def last_name(num=1, return_list=False):
|
|
"""
|
|
Generate family names, also known as surnames or last names.
|
|
|
|
Keyword args:
|
|
num (int) - How many names to return.
|
|
return_list (bool) - Whether to always return a list. `False` by default,
|
|
which returns a string if there is only one value and a list if more.
|
|
"""
|
|
# validate num first
|
|
num = int(num)
|
|
if num < 1:
|
|
raise ValueError("Number of names to generate must be positive.")
|
|
|
|
# take a random selection of `num` names, without repeats
|
|
results = random.sample(_SURNAME_LIST,num)
|
|
|
|
if len(results) == 1 and not return_list:
|
|
# return single value as a string
|
|
return results[0]
|
|
|
|
return results
|
|
|
|
def full_name(num=1, parts=2, gender=None, return_list=False, surname_first=False):
|
|
"""
|
|
Generate complete names with a personal name, family name, and optionally middle names.
|
|
|
|
Keyword args:
|
|
num (int) - How many names to return.
|
|
parts (int) - How many parts the name should have. By default two: first and last.
|
|
gender (str) - Restrict names by gender association. `None` by default, which selects from
|
|
all possible names. Set to "m" for masculine, "f" for feminine, "mf" for androgynous
|
|
return_list (bool) - Whether to always return a list. `False` by default,
|
|
which returns a string if there is only one value and a list if more.
|
|
surname_first (bool) - Default `False`. Set to `True` if you want the family name to be
|
|
placed at the beginning of the name instead of the end.
|
|
"""
|
|
# validate num first
|
|
num = int(num)
|
|
if num < 1:
|
|
raise ValueError("Number of names to generate must be positive.")
|
|
# validate parts next
|
|
parts = int(parts)
|
|
if parts < 2:
|
|
raise ValueError("Number of name parts to generate must be at least 2.")
|
|
|
|
name_lists = []
|
|
|
|
middle = parts-2
|
|
if middle:
|
|
# calculate "middle" names.
|
|
# we want them to be an intelligent mix of personal names and family names
|
|
# first, split the total number of middle-name parts into "personal" and "family" at a random point
|
|
total_mids = middle*num
|
|
personals = random.randint(1,total_mids)
|
|
familys = total_mids - personals
|
|
# then get the names for each
|
|
personal_mids = first_name(num=personals, gender=gender, return_list=True)
|
|
family_mids = last_name(num=familys, return_list=True) if familys else []
|
|
# splice them together according to surname_first....
|
|
middle_names = family_mids+personal_mids if surname_first else personal_mids+family_mids
|
|
# ...and then split into `num`-length lists to be used for the final names
|
|
name_lists = [ middle_names[num*i:num*(i+1)] for i in range(0,middle) ]
|
|
|
|
# get personal and family names
|
|
personal_names = first_name(num=num, gender=gender, return_list=True)
|
|
last_names = last_name(num=num, return_list=True)
|
|
|
|
# attach personal/family names to the list of name lists, according to surname_first
|
|
if surname_first:
|
|
name_lists = [last_names] + name_lists + [personal_names]
|
|
else:
|
|
name_lists = [personal_names] + name_lists + [last_names]
|
|
|
|
# lastly, zip them all up and join them together
|
|
names = list(zip(*name_lists))
|
|
names = [ " ".join(name) for name in names ]
|
|
|
|
if len(names) == 1 and not return_list:
|
|
# return single value as a string
|
|
return names[0]
|
|
|
|
return names
|