Merge branch 'develop' of github.com:Tegiminis/evennia into develop

This commit is contained in:
Tegiminis 2022-08-02 12:01:30 -07:00
commit 7618cf752e
51 changed files with 32888 additions and 269 deletions

View file

@ -189,7 +189,7 @@ class EventCharacter(DefaultCharacter):
"""Return the CallbackHandler."""
return CallbackHandler(self)
def announce_move_from(self, destination, msg=None, mapping=None):
def announce_move_from(self, destination, msg=None, move_type="move", mapping=None, **kwargs):
"""
Called if the move is to be announced. This is
called while we are still standing in the old
@ -234,9 +234,9 @@ class EventCharacter(DefaultCharacter):
if not string:
return
super().announce_move_from(destination, msg=string, mapping=mapping)
super().announce_move_from(destination, msg=string, move_type=move_type, mapping=mapping, **kwargs)
def announce_move_to(self, source_location, msg=None, mapping=None):
def announce_move_to(self, source_location, msg=None, move_type="move", mapping=None, **kwargs):
"""
Called after the move if the move was not quiet. At this point
we are standing in the new location.
@ -292,9 +292,9 @@ class EventCharacter(DefaultCharacter):
if not string:
return
super().announce_move_to(source_location, msg=string, mapping=mapping)
super().announce_move_to(source_location, msg=string, move_type=move_type, mapping=mapping, **kwargs)
def at_pre_move(self, destination):
def at_pre_move(self, destination, move_type="move", **kwargs):
"""
Called just before starting to move this object to
destination.
@ -334,7 +334,7 @@ class EventCharacter(DefaultCharacter):
return True
def at_post_move(self, source_location):
def at_post_move(self, source_location, move_type="move", **kwargs):
"""
Called after move has completed, regardless of quiet mode or
not. Allows changes to the object due to the location it is
@ -644,7 +644,7 @@ class EventExit(DefaultExit):
"""Return the CallbackHandler."""
return CallbackHandler(self)
def at_traverse(self, traversing_object, target_location):
def at_traverse(self, traversing_object, target_location, **kwargs):
"""
This hook is responsible for handling the actual traversal,
normally by calling
@ -665,7 +665,7 @@ class EventExit(DefaultExit):
if not allow:
return
super().at_traverse(traversing_object, target_location)
super().at_traverse(traversing_object, target_location, **kwargs)
# After traversing
if is_character:
@ -732,7 +732,7 @@ class EventObject(DefaultObject):
"""Return the CallbackHandler."""
return CallbackHandler(self)
def at_get(self, getter):
def at_get(self, getter, **kwargs):
"""
Called by the default `get` command when this object has been
picked up.
@ -745,10 +745,10 @@ class EventObject(DefaultObject):
permissions for that.
"""
super().at_get(getter)
super().at_get(getter, **kwargs)
self.callbacks.call("get", getter, self)
def at_drop(self, dropper):
def at_drop(self, dropper, **kwargs):
"""
Called by the default `drop` command when this object has been
dropped.
@ -761,7 +761,7 @@ class EventObject(DefaultObject):
permissions from that.
"""
super().at_drop(dropper)
super().at_drop(dropper, **kwargs)
self.callbacks.call("drop", dropper, self)

View file

@ -236,7 +236,7 @@ class CmdGiveUp(CmdEvscapeRoom):
# manually call move hooks
self.room.msg_room(self.caller, f"|r{self.caller.key} gave up and was whisked away!|n")
self.room.at_object_leave(self.caller, self.caller.home)
self.caller.move_to(self.caller.home, quiet=True, move_hooks=False)
self.caller.move_to(self.caller.home, quiet=True, move_hooks=False, move_type="teleport")
# back to menu
run_evscaperoom_menu(self.caller)

View file

@ -185,7 +185,7 @@ class EvscapeRoom(EvscaperoomObject, DefaultRoom):
# Evennia hooks
def at_object_receive(self, moved_obj, source_location):
def at_object_receive(self, moved_obj, source_location, move_type="move", **kwargs):
"""
Called when an object arrives in the room. This can be used to
sum up the situation, set tags etc.
@ -195,7 +195,7 @@ class EvscapeRoom(EvscaperoomObject, DefaultRoom):
self.log(f"JOIN: {moved_obj} joined room")
self.state.character_enters(moved_obj)
def at_object_leave(self, moved_obj, target_location, **kwargs):
def at_object_leave(self, moved_obj, target_location, move_type="move", **kwargs):
"""
Called when an object leaves the room; if this is a Character we need
to clean them up and move them to the menu state.

View file

@ -611,7 +611,7 @@ class CmdDrop(MuxCommand):
if obj.db.worn:
obj.remove(caller, quiet=True)
obj.move_to(caller.location, quiet=True)
obj.move_to(caller.location, quiet=True, move_type="drop")
caller.msg("You drop %s." % (obj.name,))
caller.location.msg_contents("%s drops %s." % (caller.name, obj.name), exclude=caller)
# Call the object script's at_drop() method.
@ -664,10 +664,10 @@ class CmdGive(MuxCommand):
# Remove clothes if they're given.
if to_give.db.worn:
to_give.remove(caller)
to_give.move_to(caller.location, quiet=True)
to_give.move_to(caller.location, quiet=True, move_type="remove")
# give object
caller.msg("You give %s to %s." % (to_give.key, target.key))
to_give.move_to(target, quiet=True)
to_give.move_to(target, quiet=True, move_type="give")
target.msg("%s gives you %s." % (caller.key, to_give.key))
# Call the object script's at_give() method.
to_give.at_give(caller, target)

View file

@ -82,7 +82,7 @@ class SlowExit(DefaultExit):
def move_callback():
"This callback will be called by utils.delay after move_delay seconds."
source_location = traversing_object.location
if traversing_object.move_to(target_location):
if traversing_object.move_to(target_location, move_type="traverse"):
self.at_post_traverse(traversing_object, source_location)
else:
if self.db.err_traverse:

View file

@ -538,7 +538,7 @@ class WildernessRoom(DefaultRoom):
# This object wasn't in the wilderness yet. Let's add it.
itemcoords[moved_obj] = self.coordinates
def at_object_leave(self, moved_obj, target_location):
def at_object_leave(self, moved_obj, target_location, move_type="move", **kwargs):
"""
Called just before an object leaves from inside this object. This is a
default Evennia hook.

View file

@ -202,7 +202,7 @@ _RE_RIGHT_BRACKETS = re.compile(r"\}+", _RE_FLAGS)
_RE_REF = re.compile(r"\{+\#([0-9]+[\^\~tv]{0,1})\}+")
# This regex is used to quickly reference one self in an emote.
_RE_SELF_REF = re.compile(r"/me|@", _RE_FLAGS)
_RE_SELF_REF = re.compile(r"(/me|@)(?=\W+)", _RE_FLAGS)
# regex for non-alphanumberic end of a string
_RE_CHAREND = re.compile(r"\W+$", _RE_FLAGS)
@ -213,6 +213,7 @@ _RE_REF_LANG = re.compile(r"\{+\##([0-9]+)\}+")
# this regex returns in groups (langname, say), where langname can be empty.
_RE_LANGUAGE = re.compile(r"(?:\((\w+)\))*(\".+?\")")
# the emote parser works in two steps:
# 1) convert the incoming emote into an intermediary
# form with all object references mapped to ids.
@ -235,6 +236,26 @@ class RecogError(Exception):
class LanguageError(Exception):
pass
def _get_case_ref(string):
"""
Helper function which parses capitalization and
returns the appropriate case-ref character for emotes.
"""
# default to retaining the original case
case = "~"
# internal flags for the case used for the original /query
# - t for titled input (like /Name)
# - ^ for all upercase input (like /NAME)
# - v for lower-case input (like /name)
# - ~ for mixed case input (like /nAmE)
if string.istitle():
case = "t"
elif string.isupper():
case = "^"
elif string.islower():
case = "v"
return case
# emoting mechanisms
def parse_language(speaker, emote):
@ -339,7 +360,7 @@ def parse_sdescs_and_recogs(sender, candidates, string, search_mode=False, case_
"""
# build a list of candidates with all possible referrable names
# include 'me' keyword for self-ref
candidate_map = [(sender, "me")]
candidate_map = []
for obj in candidates:
# check if sender has any recogs for obj and add
if hasattr(sender, "recog"):
@ -365,6 +386,15 @@ def parse_sdescs_and_recogs(sender, candidates, string, search_mode=False, case_
errors = []
obj = None
nmatches = 0
# first, find and replace any self-refs
for self_match in list(_RE_SELF_REF.finditer(string)):
matched = self_match.group()
case = _get_case_ref(matched.lstrip(_PREFIX)) if case_sensitive else ""
key = f"#{sender.id}{case}"
# replaced with ref
string = _RE_SELF_REF.sub(f"{{{key}}}", string, count=1)
mapping[key] = sender
for marker_match in reversed(list(_RE_OBJ_REF_START.finditer(string))):
# we scan backwards so we can replace in-situ without messing
# up later occurrences. Given a marker match, query from
@ -375,7 +405,7 @@ def parse_sdescs_and_recogs(sender, candidates, string, search_mode=False, case_
match_index = marker_match.start()
# split the emote string at the reference marker, to process everything after it
head = string[:match_index]
tail = string[match_index + 1 :]
tail = string[match_index + 1:]
if search_mode:
# match the candidates against the whole search string after the marker
@ -421,7 +451,7 @@ def parse_sdescs_and_recogs(sender, candidates, string, search_mode=False, case_
# save search string
matched_text = "".join(tail[1:iend])
# recombine remainder of emote back into a string
tail = "".join(tail[iend + 1 :])
tail = "".join(tail[iend + 1:])
nmatches = len(bestmatches)
@ -456,24 +486,9 @@ def parse_sdescs_and_recogs(sender, candidates, string, search_mode=False, case_
errors.append(_EMOTE_NOMATCH_ERROR.format(ref=marker_match.group()))
elif nmatches == 1:
# a unique match - parse into intermediary representation
case = "~" # retain original case of sdesc
if case_sensitive:
# case sensitive mode
# internal flags for the case used for the original /query
# - t for titled input (like /Name)
# - ^ for all upercase input (like /NAME)
# - v for lower-case input (like /name)
# - ~ for mixed case input (like /nAmE)
matchtext = marker_match.group().lstrip(_PREFIX)
if matchtext.istitle():
case = "t"
elif matchtext.isupper():
case = "^"
elif matchtext.islower():
case = "v"
key = f"#{obj.id}{case}"
case = _get_case_ref(marker_match.group()) if case_sensitive else ""
# recombine emote with matched text replaced by ref
key = f"#{obj.id}{case}"
string = f"{head}{{{key}}}{tail}"
mapping[key] = obj
@ -513,7 +528,7 @@ def parse_sdescs_and_recogs(sender, candidates, string, search_mode=False, case_
return string, mapping
def send_emote(sender, receivers, emote, anonymous_add="first", **kwargs):
def send_emote(sender, receivers, emote, msg_type="pose", anonymous_add="first", **kwargs):
"""
Main access function for distribute an emote.
@ -523,6 +538,9 @@ def send_emote(sender, receivers, emote, anonymous_add="first", **kwargs):
will also form the basis for which sdescs are
'valid' to use in the emote.
emote (str): The raw emote string as input by emoter.
msg_type (str): The type of emote this is. "say" or "pose"
for example. This is arbitrary and used for generating
extra data for .msg(text) tuple.
anonymous_add (str or None, optional): If `sender` is not
self-referencing in the emote, this will auto-add
`sender`'s data to the emote. Possible values are
@ -599,7 +617,7 @@ def send_emote(sender, receivers, emote, anonymous_add="first", **kwargs):
)
# do the template replacement of the sdesc/recog {#num} markers
receiver.msg(sendemote.format(**receiver_sdesc_mapping), from_obj=sender, **kwargs)
receiver.msg(text=(sendemote.format(**receiver_sdesc_mapping), {"type": msg_type}), from_obj=sender, **kwargs)
# ------------------------------------------------------------
@ -910,7 +928,7 @@ class CmdSay(RPCommand): # replaces standard say
# calling the speech modifying hook
speech = caller.at_pre_say(self.args)
targets = self.caller.location.contents
send_emote(self.caller, targets, speech, anonymous_add=None)
send_emote(self.caller, targets, speech, msg_type="say", anonymous_add=None)
class CmdSdesc(RPCommand): # set/look at own sdesc
@ -1253,19 +1271,19 @@ class ContribRPObject(DefaultObject):
self.sdesc.add("Something")
def search(
self,
searchdata,
global_search=False,
use_nicks=True,
typeclass=None,
location=None,
attribute_name=None,
quiet=False,
exact=False,
candidates=None,
nofound_string=None,
multimatch_string=None,
use_dbref=None,
self,
searchdata,
global_search=False,
use_nicks=True,
typeclass=None,
location=None,
attribute_name=None,
quiet=False,
exact=False,
candidates=None,
nofound_string=None,
multimatch_string=None,
use_dbref=None,
):
"""
Returns an Object matching a search string/condition, taking
@ -1349,10 +1367,10 @@ class ContribRPObject(DefaultObject):
)
if global_search or (
is_string
and searchdata.startswith("#")
and len(searchdata) > 1
and searchdata[1:].isdigit()
is_string
and searchdata.startswith("#")
and len(searchdata) > 1
and searchdata[1:].isdigit()
):
# only allow exact matching if searching the entire database
# or unique #dbrefs

View file

@ -97,6 +97,7 @@ recog02 = "Mr Receiver2"
recog10 = "Mr Sender"
emote = 'With a flair, /me looks at /first and /colliding sdesc-guy. She says "This is a test."'
case_emote = "/Me looks at /first. Then, /me looks at /FIRST, /First and /Colliding twice."
poss_emote = "/Me frowns at /first for trying to steal /me's test."
class TestRPSystem(BaseEvenniaTest):
@ -140,18 +141,21 @@ class TestRPSystem(BaseEvenniaTest):
),
)
def parse_sdescs_and_recogs(self):
def test_parse_sdescs_and_recogs(self):
speaker = self.speaker
speaker.sdesc.add(sdesc0)
self.receiver1.sdesc.add(sdesc1)
self.receiver2.sdesc.add(sdesc2)
id0 = f"#{speaker.id}"
id1 = f"#{self.receiver1.id}"
id2 = f"#{self.receiver2.id}"
candidates = (self.receiver1, self.receiver2)
result = (
'With a flair, {#9} looks at {#10} and {#11}. She says "This is a test."',
'With a flair, {'+id0+'} looks at {'+id1+'} and {'+id2+'}. She says "This is a test."',
{
"#11": "Another nice colliding sdesc-guy for tests",
"#10": "The first receiver of emotes.",
"#9": "A nice sender of emotes",
id2: self.receiver2,
id1: self.receiver1,
id0: speaker,
},
)
self.assertEqual(
@ -164,6 +168,27 @@ class TestRPSystem(BaseEvenniaTest):
result,
)
def test_possessive_selfref(self):
speaker = self.speaker
speaker.sdesc.add(sdesc0)
self.receiver1.sdesc.add(sdesc1)
self.receiver2.sdesc.add(sdesc2)
id0 = f"#{speaker.id}"
id1 = f"#{self.receiver1.id}"
id2 = f"#{self.receiver2.id}"
candidates = (self.receiver1, self.receiver2)
result = (
"{"+id0+"} frowns at {"+id1+"} for trying to steal {"+id0+"}'s test.",
{
id1: self.receiver1,
id0: speaker,
},
)
self.assertEqual(
rpsystem.parse_sdescs_and_recogs(speaker, candidates, poss_emote, case_sensitive=False),
result,
)
def test_get_sdesc(self):
looker = self.speaker # Sender
target = self.receiver1 # Receiver1
@ -197,17 +222,17 @@ class TestRPSystem(BaseEvenniaTest):
receiver2.msg = lambda text, **kwargs: setattr(self, "out2", text)
rpsystem.send_emote(speaker, receivers, emote, case_sensitive=False)
self.assertEqual(
self.out0,
self.out0[0],
"With a flair, |mSender|n looks at |bThe first receiver of emotes.|n "
'and |bAnother nice colliding sdesc-guy for tests|n. She says |w"This is a test."|n',
)
self.assertEqual(
self.out1,
self.out1[0],
"With a flair, |bA nice sender of emotes|n looks at |mReceiver1|n and "
'|bAnother nice colliding sdesc-guy for tests|n. She says |w"This is a test."|n',
)
self.assertEqual(
self.out2,
self.out2[0],
"With a flair, |bA nice sender of emotes|n looks at |bThe first "
'receiver of emotes.|n and |mReceiver2|n. She says |w"This is a test."|n',
)
@ -226,19 +251,19 @@ class TestRPSystem(BaseEvenniaTest):
receiver2.msg = lambda text, **kwargs: setattr(self, "out2", text)
rpsystem.send_emote(speaker, receivers, case_emote)
self.assertEqual(
self.out0,
self.out0[0],
"|mSender|n looks at |bthe first receiver of emotes.|n. Then, |mSender|n "
"looks at |bTHE FIRST RECEIVER OF EMOTES.|n, |bThe first receiver of emotes.|n "
"and |bAnother nice colliding sdesc-guy for tests|n twice.",
)
self.assertEqual(
self.out1,
self.out1[0],
"|bA nice sender of emotes|n looks at |mReceiver1|n. Then, "
"|ba nice sender of emotes|n looks at |mReceiver1|n, |mReceiver1|n "
"and |bAnother nice colliding sdesc-guy for tests|n twice.",
)
self.assertEqual(
self.out2,
self.out2[0],
"|bA nice sender of emotes|n looks at |bthe first receiver of emotes.|n. "
"Then, |ba nice sender of emotes|n looks at |bTHE FIRST RECEIVER OF EMOTES.|n, "
"|bThe first receiver of emotes.|n and |mReceiver2|n twice.",

View file

@ -225,7 +225,7 @@ class CmdTutorialGiveUp(default_cmds.MuxCommand):
)
return
self.caller.move_to(outro_room)
self.caller.move_to(outro_room, move_type="teleport")
class TutorialRoomCmdSet(CmdSet):
@ -259,7 +259,7 @@ class TutorialRoom(DefaultRoom):
)
self.cmdset.add_default(TutorialRoomCmdSet)
def at_object_receive(self, new_arrival, source_location):
def at_object_receive(self, new_arrival, source_location, move_type="move", **kwargs):
"""
When an object enter a tutorial room we tell other objects in
the room about it by trying to call a hook on them. The Mob object
@ -451,7 +451,7 @@ class IntroRoom(TutorialRoom):
"the account."
)
def at_object_receive(self, character, source_location):
def at_object_receive(self, character, source_location, move_type="move", **kwargs):
"""
Assign properties on characters
"""
@ -523,7 +523,7 @@ class CmdEast(Command):
# Move to the east room.
eexit = search_object(self.obj.db.east_exit)
if eexit:
caller.move_to(eexit[0])
caller.move_to(eexit[0], move_type="traverse")
else:
caller.msg("No east exit was found for this room. Contact an admin.")
return
@ -570,7 +570,7 @@ class CmdWest(Command):
# Move to the west room.
wexit = search_object(self.obj.db.west_exit)
if wexit:
caller.move_to(wexit[0])
caller.move_to(wexit[0], move_type="traverse")
else:
caller.msg("No west exit was found for this room. Contact an admin.")
return
@ -658,7 +658,7 @@ class CmdLookBridge(Command):
fall_exit = search_object(self.obj.db.fall_exit)
if fall_exit:
self.caller.msg("|r%s|n" % FALL_MESSAGE)
self.caller.move_to(fall_exit[0], quiet=True)
self.caller.move_to(fall_exit[0], quiet=True, move_type="fall")
# inform others on the bridge
self.obj.msg_contents(
"A plank gives way under %s's feet and "
@ -770,7 +770,7 @@ class BridgeRoom(WeatherRoom):
# send a message most of the time
self.msg_contents("|w%s|n" % random.choice(BRIDGE_WEATHER))
def at_object_receive(self, character, source_location):
def at_object_receive(self, character, source_location, move_type="move", **kwargs):
"""
This hook is called by the engine whenever the player is moved
into this room.
@ -796,7 +796,7 @@ class BridgeRoom(WeatherRoom):
character.db.tutorial_bridge_position = 0
character.execute_cmd("look")
def at_object_leave(self, character, target_location):
def at_object_leave(self, character, target_location, move_type="move", **kwargs):
"""
This is triggered when the player leaves the bridge room.
"""
@ -1038,7 +1038,7 @@ class DarkRoom(TutorialRoom):
# put players in darkness
char.msg("The room is completely dark.")
def at_object_receive(self, obj, source_location):
def at_object_receive(self, obj, source_location, move_type="move", **kwargs):
"""
Called when an object enters the room.
"""
@ -1048,7 +1048,7 @@ class DarkRoom(TutorialRoom):
# in case the new guy carries light with them
self.check_light_state()
def at_object_leave(self, obj, target_location):
def at_object_leave(self, obj, target_location, move_type="move", **kwargs):
"""
In case people leave with the light, we make sure to clear the
DarkCmdSet if necessary. This also works if they are
@ -1103,7 +1103,7 @@ class TeleportRoom(TutorialRoom):
self.db.failure_teleport_msg = "You fail!"
self.db.failure_teleport_to = "dark cell"
def at_object_receive(self, character, source_location):
def at_object_receive(self, character, source_location, move_type="move", **kwargs):
"""
This hook is called by the engine whenever the player is moved into
this room.
@ -1130,7 +1130,7 @@ class TeleportRoom(TutorialRoom):
else:
character.msg(self.db.failure_teleport_msg)
# teleport quietly to the new place
character.move_to(results[0], quiet=True, move_hooks=False)
character.move_to(results[0], quiet=True, move_hooks=False, move_type="teleport")
# we have to call this manually since we turn off move_hooks
# - this is necessary to make the target dark room aware of an
# already carried light.
@ -1167,7 +1167,7 @@ class OutroRoom(TutorialRoom):
"character."
)
def at_object_receive(self, character, source_location):
def at_object_receive(self, character, source_location, move_type="move", **kwargs):
"""
Do cleanup.
"""
@ -1183,6 +1183,6 @@ class OutroRoom(TutorialRoom):
obj.delete()
character.tags.clear(category="tutorial_world")
def at_object_leave(self, character, destination):
def at_object_leave(self, character, destination, move_type="move", **kwargs):
if character.account:
character.account.execute_cmd("unquell")

View file

@ -160,7 +160,7 @@ class TestTutorialWorldRooms(BaseEvenniaCommandTest):
def test_bridgeroom(self):
room = create_object(tutrooms.BridgeRoom, key="bridgeroom")
room.update_weather()
self.char1.move_to(room)
self.char1.move_to(room, move_type="teleport")
self.call(
tutrooms.CmdBridgeHelp(),
"",
@ -181,7 +181,7 @@ class TestTutorialWorldRooms(BaseEvenniaCommandTest):
def test_darkroom(self):
room = create_object(tutrooms.DarkRoom, key="darkroom")
self.char1.move_to(room)
self.char1.move_to(room, move_type="teleport")
self.call(tutrooms.CmdDarkHelp(), "", "Can't help you until")
def test_teleportroom(self):

View file

@ -0,0 +1,277 @@
# 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`
## Installation
This is a stand-alone utility. Just import this module (`from evennia.contrib.utils import name_generator`) and use its functions wherever you like.
## Usage
Import the module where you need it with the following:
```py
from evennia.contrib.utils.name_generator import namegen
```
By default, all of the functions will return a string with one generated name.
If you specify more than one, or pass `return_list=True` as a keyword argument, the returned value will be a list of strings.
The module is especially useful for naming newly-created NPCs, like so:
```py
npc_name = namegen.full_name()
npc_obj = create_object(key=npc_name, typeclass="typeclasses.characters.NPC")
```
## Available Settings
These settings can all be defined in your game's `server/conf/settings.py` file.
- `NAMEGEN_FIRST_NAMES` adds a new list of first (personal) names.
- `NAMEGEN_LAST_NAMES` adds a new list of last (family) names.
- `NAMEGEN_REPLACE_LISTS` - set to `True` if you want to use only the names defined in your settings.
- `NAMEGEN_FANTASY_RULES` lets you add new phonetic rules for generating entirely made-up names. See the section "Custom Fantasy Name style rules" for details on how this should look.
Examples:
```py
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),
}
}
```
## Generating Real Names
The contrib offers three functions for generating random real-world names:
`first_name()`, `last_name()`, and `full_name()`. If you want more than one name
generated at once, you can use the `num` keyword argument to specify how many.
Example:
```
>>> namegen.first_name(num=5)
['Genesis', 'Tali', 'Budur', 'Dominykas', 'Kamau']
>>> namegen.first_name(gender='m')
'Blanchard'
```
The `first_name` function also takes a `gender` keyword argument to filter names
by gender association. 'f' for feminine, 'm' for masculine, 'mf' for feminine
_and_ masculine, or the default `None` to match any gendering.
The `full_name` function also takes the `gender` keyword, as well as `parts` which
defines how many names make up the full name. The minimum is two: a first name and
a last name. You can also generate names with the family name first by setting
the keyword arg `surname_first` to `True`
Example:
```
>>> namegen.full_name()
'Keeva Bernat'
>>> namegen.full_name(parts=4)
'Suzu Shabnam Kafka Baier'
>>> namegen.full_name(parts=3, surname_first=True)
'Ó Muircheartach Torunn Dyson'
>>> namegen.full_name(gender='f')
'Wikolia Ó Deasmhumhnaigh'
```
### Adding your own names
You can add additional names with the settings `NAMEGEN_FIRST_NAMES` and
`NAMEGEN_LAST_NAMES`
`NAMEGEN_FIRST_NAMES` should be a list of tuples, where the first value is the name
and then second value is the gender flag - 'm' for masculine-only, 'f' for feminine-
only, and 'mf' for either one.
`NAMEGEN_LAST_NAMES` should be a list of strings, where each item is an available
surname.
Examples:
```py
NAMEGEN_FIRST_NAMES = [
("Evennia", 'mf'),
("Green Tea", 'f'),
]
NAMEGEN_LAST_NAMES = [ "Beeblebrox", "Son of Odin" ]
```
Set `NAMEGEN_REPLACE_LISTS = True` if you want your custom lists above to entirely replace the built-in lists rather than extend them.
## Generating Fantasy Names
Generating completely made-up names is done with the `fantasy_name` function. The
contrib comes with three built-in styles of names which you can use, or you can
put a dictionary of custom name rules into `settings.py`
Generating a fantasy name takes the ruleset key as the "style" keyword, and can
return either a single name or multiple names. By default, it will return a
single name in the built-in "harsh" style. The contrib also comes with "fluid" and "alien" styles.
```py
>>> namegen.fantasy_name()
'Vhon'
>>> namegen.fantasy_name(num=3, style="harsh")
['Kha', 'Kizdhu', 'Godögäk']
>>> namegen.fantasy_name(num=3, style="fluid")
['Aewalisash', 'Ayi', 'Iaa']
>>> namegen.fantasy_name(num=5, style="alien")
["Qz'vko'", "Xv'w'hk'hxyxyz", "Wxqv'hv'k", "Wh'k", "Xbx'qk'vz"]
```
### Multi-Word Fantasy Names
The `fantasy_name` function will only generate one name-word at a time, so for multi-word names
you'll need to combine pieces together. Depending on what kind of end result you want, there are
several approaches.
#### The simple approach
If all you need is for it to have multiple parts, you can generate multiple names at once and `join` them.
```py
>>> name = " ".join(namegen.fantasy_name(num=2))
>>> name
'Dezhvözh Khäk'
```
If you want a little more variation between first/last names, you can also generate names for
different styles and then combine them.
```py
>>> first = namegen.fantasy_name(style="fluid")
>>> last = namegen.fantasy_name(style="harsh")
>>> name = f"{first} {last}"
>>> name
'Ofasa Käkudhu'
```
#### "Nakku Silversmith"
One common fantasy name practice is profession- or title-based surnames. To achieve this effect,
you can use the `last_name` function with a custom list of last names and combine it with your generated
fantasy name.
Example:
```py
NAMEGEN_LAST_NAMES = [ "Silversmith", "the Traveller", "Destroyer of Worlds" ]
NAMEGEN_REPLACE_LISTS = True
>>> first = namegen.fantasy_name()
>>> last = namegen.last_name()
>>> name = f"{first} {last}"
>>> name
'Tözhkheko the Traveller'
```
#### Elarion d'Yrinea, Thror Obinson
Another common flavor of fantasy names is to use a surname suffix or prefix. For that, you'll
need to add in the extra bit yourself.
Examples:
```py
>>> names = namegen.fantasy_name(num=2)
>>> name = f"{names[0]} za'{names[1]}"
>>> name
"Tithe za'Dhudozkok"
>>> names = namegen.fantasy_name(num=2)
>>> name = f"{names[0]} {names[1]}son"
>>> name
'Kön Ködhöddoson'
```
### Custom Fantasy Name style rules
The style rules are contained in a dictionary of dictionaries, where the style name
is the key and the style rules are the dictionary value.
The following is how you would add a custom style to `settings.py`:
```py
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),
}
}
```
Then you could generate names following that ruleset with `namegen.fantasy_name(style="example_style")`.
The keys `syllable`, `consonants`, `vowels`, and `length` must be present, and `length` must be the minimum and maximum syllable counts. `start` and `end` are optional.
#### syllable
The "syllable" field defines the structure of each syllable. C is consonant, V is vowel,
and parentheses mean it's optional. So, the example `(C)VC` means that every syllable
will always have a vowel followed by a consonant, and will *sometimes* have another
consonant at the beginning. e.g. `en`, `bak`
*Note:* While it's not standard, the contrib lets you nest parentheses, with each layer
being less likely to show up. Additionally, any other characters put into the syllable
structure - e.g. an apostrophe - will be read and inserted as written. The
"alien" style rules in the module gives an example of both: the syllable structure is `C(C(V))(')(C)`
which results in syllables such as `khq`, `xho'q`, and `q'` with a much lower frequency of vowels than
`C(C)(V)(')(C)` would have given.
#### consonants
A simple list of consonant phonemes that can be chosen from. Multi-character strings are
perfectly acceptable, such as "th", but each one will be treated as a single consonant.
The function uses a naive form of weighting, where you make a phoneme more likely to
occur by putting more copies of it into the list.
#### start and end
These are **optional** lists for the first and last letters of a syllable, if they're
a consonant. You can add on additional consonants which can only occur at the beginning
or end of a syllable, or you can add extra copies of already-defined consonants to
increase the frequency of them at the start/end of syllables.
For example, in the `example_style` above, we have a `start` of m, and `end` of x and n.
Taken with the rest of the consonants/vowels, this means you can have the syllables of `mez`
but not `zem`, and you can have `phex` or `phen` but not `xeph` or `neph`.
They can be left out of custom rulesets entirely.
#### vowels
Vowels is a simple list of vowel phonemes - exactly like consonants, but instead used for the
vowel selection. Single-or multi-character strings are equally fine. It uses the same naive weighting system
as consonants - you can increase the frequency of any given vowel by putting it into the list multiple times.
#### length
A tuple with the minimum and maximum number of syllables a name can have.
When setting this, keep in mind how long your syllables can get! 4 syllables might
not seem like very many, but if you have a (C)(V)VC structure with one- and
two-letter phonemes, you can get up to eight characters per syllable.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,158 @@
"""
Tests for the Random Name Generator
"""
from evennia.utils.test_resources import BaseEvenniaTest
from evennia.contrib.utils.name_generator import namegen
_INVALID_STYLES = {
"missing_keys": {
"consonants": ['c','d'],
"length": (1,2),
},
"invalid_vowels": {
"syllable": "CVC",
"consonants": ['c','d'],
"vowels": "aeiou",
"length": (1,2),
},
"invalid_length": {
"syllable": "CVC",
"consonants": ['c','d'],
"vowels": ['a','e'],
"length": 2,
},
}
namegen._FANTASY_NAME_STRUCTURES |= _INVALID_STYLES
class TestNameGenerator(BaseEvenniaTest):
def test_fantasy_name(self):
"""
Verify output types and lengths.
fantasy_name() - str
fantasy_name(style="fluid") - str
fantasy_name(num=3) - list of length 3
fantasy_name(return_list=True) - list of length 1
raises KeyError on missing style or ValueError on num
"""
single_name = namegen.fantasy_name()
self.assertEqual(type(single_name), str)
fluid_name = namegen.fantasy_name(style="fluid")
self.assertEqual(type(fluid_name), str)
three_names = namegen.fantasy_name(num=3)
self.assertEqual(type(three_names), list)
self.assertEqual(len(three_names), 3)
single_list = namegen.fantasy_name(return_list=True)
self.assertEqual(type(single_list), list)
self.assertEqual(len(single_list), 1)
with self.assertRaises(ValueError):
namegen.fantasy_name(num=-1)
with self.assertRaises(ValueError):
namegen.fantasy_name(style="dummy")
def test_structure_validation(self):
"""
Verify that validation raises the correct errors for invalid inputs.
"""
with self.assertRaises(KeyError):
namegen.fantasy_name(style="missing_keys")
with self.assertRaises(TypeError):
namegen.fantasy_name(style="invalid_vowels")
with self.assertRaises(ValueError):
namegen.fantasy_name(style="invalid_length")
def test_first_name(self):
"""
Verify output types and lengths.
first_name() - str
first_name(num=3) - list of length 3
first_name(gender='f') - str
first_name(return_list=True) - list of length 1
"""
single_name = namegen.first_name()
self.assertEqual(type(single_name), str)
three_names = namegen.first_name(num=3)
self.assertEqual(type(three_names), list)
self.assertEqual(len(three_names), 3)
gendered_name = namegen.first_name(gender='f')
self.assertEqual(type(gendered_name), str)
single_list = namegen.first_name(return_list=True)
self.assertEqual(type(single_list), list)
self.assertEqual(len(single_list), 1)
with self.assertRaises(ValueError):
namegen.first_name(gender='x')
with self.assertRaises(ValueError):
namegen.first_name(num=-1)
def test_last_name(self):
"""
Verify output types and lengths.
last_name() - str
last_name(num=3) - list of length 3
last_name(return_list=True) - list of length 1
"""
single_name = namegen.last_name()
self.assertEqual(type(single_name), str)
three_names = namegen.last_name(num=3)
self.assertEqual(type(three_names), list)
self.assertEqual(len(three_names), 3)
single_list = namegen.last_name(return_list=True)
self.assertEqual(type(single_list), list)
self.assertEqual(len(single_list), 1)
with self.assertRaises(ValueError):
namegen.last_name(num=-1)
def test_full_name(self):
"""
Verify output types and lengths.
full_name() - str
full_name(num=3) - list of length 3
full_name(gender='f') - str
full_name(return_list=True) - list of length 1
"""
single_name = namegen.full_name()
self.assertEqual(type(single_name), str)
three_names = namegen.full_name(num=3)
self.assertEqual(type(three_names), list)
self.assertEqual(len(three_names), 3)
gendered_name = namegen.full_name(gender='f')
self.assertEqual(type(gendered_name), str)
single_list = namegen.full_name(return_list=True)
self.assertEqual(type(single_list), list)
self.assertEqual(len(single_list), 1)
parts_name = namegen.full_name(parts=4)
# a name made of 4 parts must have at least 3 spaces, but may have more
parts = parts_name.split(" ")
self.assertGreaterEqual(len(parts), 3)
with self.assertRaises(ValueError):
namegen.full_name(parts=1)
with self.assertRaises(ValueError):
namegen.full_name(num=-1)