From f782cd8fc88c866e99d91df1491c627819e4d824 Mon Sep 17 00:00:00 2001 From: Andrew Bastien Date: Sat, 6 May 2023 02:31:07 -0400 Subject: [PATCH 1/6] Removing direct access to Account.db._playable_characters in favor of Account.add_character(char) and Account.remove_character(char). Account.characters already handles all filtering so am cleaning up lots of repeated list comprehensions which remove/filter deleted characters. --- evennia/accounts/accounts.py | 54 +++++++++++----- evennia/commands/default/account.py | 23 +++---- evennia/commands/default/unloggedin.py | 61 +++++++++++++++++++ .../character_creator/character_creator.py | 6 +- .../contrib/tutorials/evadventure/chargen.py | 2 +- evennia/objects/objects.py | 8 +-- evennia/server/initial_setup.py | 14 ++--- evennia/server/server.py | 5 +- evennia/web/admin/objects.py | 9 +-- evennia/web/website/views/help.py | 2 +- 10 files changed, 124 insertions(+), 60 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index eb7e8b5d7..d0608adf7 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -236,6 +236,39 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): return objs + def add_character(self, character: "DefaultCharacter"): + """ + Add a character to this account's list of playable characters. + """ + if character not in self.db._playable_characters: + self.db._playable_characters.append(character) + self.at_post_add_character(character) + + def at_post_add_character(self, character: "DefaultCharacter"): + """ + Called after a character is added to this account's list of playable characters. + + Use it to easily implement custom logic when a character is added to an account. + """ + pass + + def remove_character(self, character): + """ + Remove a character from this account's list of playable characters. + """ + if character in self.db._playable_characters: + self.db._playable_characters.remove(character) + self.at_post_remove_character(character) + + def at_post_remove_character(self, character): + """ + Called after a character is removed from this account's list of playable characters. + + Use it to easily implement custom logic when a character is removed from an account. + """ + pass + + def uses_screenreader(self, session=None): """ Shortcut to determine if a session uses a screenreader. If no session given, @@ -743,8 +776,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): ) if character: # Update playable character list - if character not in self.characters: - self.db._playable_characters.append(character) + self.add_character(character) # We need to set this to have @ic auto-connect to this character self.db._last_puppet = character @@ -1483,11 +1515,8 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): else: # In this mode we don't auto-connect but by default end up at a character selection # screen. We execute look on the account. - # we make sure to clean up the _playable_characters list in case - # any was deleted in the interim. - self.db._playable_characters = [char for char in self.db._playable_characters if char] self.msg( - self.at_look(target=self.db._playable_characters, session=session), session=session + self.at_look(target=self.characters, session=session), session=session ) def at_failed_login(self, session, **kwargs): @@ -1825,11 +1854,8 @@ class DefaultGuest(DefaultAccount): be on the safe side. """ super().at_server_shutdown() - characters = self.db._playable_characters - if characters: - for character in characters: - if character: - character.delete() + for character in self.characters: + character.delete() def at_post_disconnect(self, **kwargs): """ @@ -1841,8 +1867,6 @@ class DefaultGuest(DefaultAccount): """ super().at_post_disconnect() - characters = self.db._playable_characters - for character in characters: - if character: - character.delete() + for character in self.characters: + character.delete() self.delete() diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index eb0b07495..5e3f2f418 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -60,12 +60,7 @@ class MuxAccountLookCommand(COMMAND_DEFAULT_CLASS): super().parse() - playable = self.account.db._playable_characters - if playable is not None: - # clean up list if character object was deleted in between - if None in playable: - playable = [character for character in playable if character] - self.account.db._playable_characters = playable + playable = self.account.characters # store playable property if self.args: self.playable = dict((utils.to_str(char.key.lower()), char) for char in playable).get( @@ -155,8 +150,8 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS): if ( not account.is_superuser and not account.check_permstring("Developer") - and account.db._playable_characters - and len(account.db._playable_characters) >= _MAX_NR_CHARACTERS + and account.characters + and len(account.characters) >= _MAX_NR_CHARACTERS ): plural = "" if _MAX_NR_CHARACTERS == 1 else "s" self.msg(f"You may only have a maximum of {_MAX_NR_CHARACTERS} character{plural}.") @@ -184,7 +179,7 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS): "puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer);delete:id(%i) or" " perm(Admin)" % (new_character.id, account.id, account.id) ) - account.db._playable_characters.append(new_character) + account.add_character(new_character) if desc: new_character.db.desc = desc elif not new_character.db.desc: @@ -223,7 +218,7 @@ class CmdCharDelete(COMMAND_DEFAULT_CLASS): # use the playable_characters list to search match = [ char - for char in utils.make_iter(account.db._playable_characters) + for char in utils.make_iter(account.characters) if char.key.lower() == self.args.lower() ] if not match: @@ -243,9 +238,7 @@ class CmdCharDelete(COMMAND_DEFAULT_CLASS): # only take action delobj = caller.ndb._char_to_delete key = delobj.key - caller.db._playable_characters = [ - pc for pc in caller.db._playable_characters if pc != delobj - ] + caller.remove_character(delobj) delobj.delete() self.msg(f"Character '{key}' was permanently deleted.") logger.log_sec( @@ -314,13 +307,13 @@ class CmdIC(COMMAND_DEFAULT_CLASS): else: # argument given - if account.db._playable_characters: + if (playables := account.characters): # look at the playable_characters list first character_candidates.extend( utils.make_iter( account.search( self.args, - candidates=account.db._playable_characters, + candidates=playables, search_object=True, quiet=True, ) diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index 7bc58dc11..0b0179b3d 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -465,3 +465,64 @@ class CmdUnconnectedInfo(COMMAND_DEFAULT_CLASS): utils.get_evennia_version(), ) ) + + +def _create_account(session, accountname, password, permissions, typeclass=None, email=None): + """ + Helper function, creates an account of the specified typeclass. + """ + try: + new_account = create.create_account( + accountname, email, password, permissions=permissions, typeclass=typeclass + ) + + except Exception as e: + session.msg( + "There was an error creating the Account:\n%s\n If this problem persists, contact an" + " admin." % e + ) + logger.log_trace() + return False + + # This needs to be set so the engine knows this account is + # logging in for the first time. (so it knows to call the right + # hooks during login later) + new_account.db.FIRST_LOGIN = True + + # join the new account to the public channel + pchannel = ChannelDB.objects.get_channel(settings.DEFAULT_CHANNELS[0]["key"]) + if not pchannel or not pchannel.connect(new_account): + string = "New account '%s' could not connect to public channel!" % new_account.key + logger.log_err(string) + return new_account + + +def _create_character(session, new_account, typeclass, home, permissions): + """ + Helper function, creates a character based on an account's name. + This is meant for Guest and AUTO_CREATRE_CHARACTER_WITH_ACCOUNT=True situations. + """ + try: + new_character = create.create_object( + typeclass, key=new_account.key, home=home, permissions=permissions + ) + # set playable character list + new_account.add_character(new_character) + + # allow only the character itself and the account to puppet this character (and Developers). + new_character.locks.add( + "puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer)" + % (new_character.id, new_account.id) + ) + + # If no description is set, set a default description + if not new_character.db.desc: + new_character.db.desc = "This is a character." + # We need to set this to have ic auto-connect to this character + new_account.db._last_puppet = new_character + except Exception as e: + session.msg( + "There was an error creating the Character:\n%s\n If this problem persists, contact an" + " admin." % e + ) + logger.log_trace() diff --git a/evennia/contrib/rpg/character_creator/character_creator.py b/evennia/contrib/rpg/character_creator/character_creator.py index 354769823..d70a6be0a 100644 --- a/evennia/contrib/rpg/character_creator/character_creator.py +++ b/evennia/contrib/rpg/character_creator/character_creator.py @@ -54,7 +54,7 @@ class ContribCmdCharCreate(MuxAccountCommand): session = self.session # only one character should be in progress at a time, so we check for WIPs first - in_progress = [chara for chara in account.db._playable_characters if chara.db.chargen_step] + in_progress = [chara for chara in account.characters if chara.db.chargen_step] if len(in_progress): # we're continuing chargen for a WIP character @@ -64,7 +64,7 @@ class ContribCmdCharCreate(MuxAccountCommand): charmax = settings.MAX_NR_CHARACTERS if not account.is_superuser and ( - account.db._playable_characters and len(account.db._playable_characters) >= charmax + account.characters and len(account.characters) >= charmax ): plural = "" if charmax == 1 else "s" self.msg(f"You may only create a maximum of {charmax} character{plural}.") @@ -90,7 +90,7 @@ class ContribCmdCharCreate(MuxAccountCommand): ) # initalize the new character to the beginning of the chargen menu new_character.db.chargen_step = "menunode_welcome" - account.db._playable_characters.append(new_character) + account.add_character(new_character) # set the menu node to start at to the character's last saved step startnode = new_character.db.chargen_step diff --git a/evennia/contrib/tutorials/evadventure/chargen.py b/evennia/contrib/tutorials/evadventure/chargen.py index 26f3df646..2236ddd7f 100644 --- a/evennia/contrib/tutorials/evadventure/chargen.py +++ b/evennia/contrib/tutorials/evadventure/chargen.py @@ -316,7 +316,7 @@ def node_apply_character(caller, raw_string, **kwargs): """ tmp_character = kwargs["tmp_character"] new_character = tmp_character.apply(caller) - caller.db._playable_characters.append(new_character) + caller.add_character(new_character) text = "Character created!" diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index ffd9e2165..5e22cc03b 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -1149,10 +1149,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): # sever the connection (important!) if self.account: # Remove the object from playable characters list - if self in self.account.db._playable_characters: - self.account.db._playable_characters = [ - x for x in self.account.db._playable_characters if x != self - ] + self.account.remove_character(self) for session in self.sessions.all(): self.account.unpuppet_object(session) @@ -2562,8 +2559,7 @@ class DefaultCharacter(DefaultObject): obj.db.creator_ip = ip if account: obj.db.creator_id = account.id - if obj not in account.characters: - account.db._playable_characters.append(obj) + account.add_character(obj) # Add locks if not locks and account: diff --git a/evennia/server/initial_setup.py b/evennia/server/initial_setup.py index ffec79ab1..cfd79fea0 100644 --- a/evennia/server/initial_setup.py +++ b/evennia/server/initial_setup.py @@ -98,16 +98,15 @@ def create_objects(): # Create the in-game god-character for account #1 and set # it to exist in Limbo. - character_typeclass = settings.BASE_CHARACTER_TYPECLASS try: superuser_character = ObjectDB.objects.get(id=1) except ObjectDB.DoesNotExist: - superuser_character = create.create_object( - character_typeclass, key=superuser.username, nohome=True + superuser_character, errors = superuser.create_character( + key=superuser.username, nohome=True, description=_("This is User #1.") ) + if errors: + raise Exception(str(errors)) - superuser_character.db_typeclass_path = character_typeclass - superuser_character.db.desc = _("This is User #1.") superuser_character.locks.add( "examine:perm(Developer);edit:false();delete:false();boot:false();msg:all();puppet:false()" ) @@ -118,11 +117,6 @@ def create_objects(): superuser.attributes.add("_first_login", True) superuser.attributes.add("_last_puppet", superuser_character) - try: - superuser.db._playable_characters.append(superuser_character) - except AttributeError: - superuser.db_playable_characters = [superuser_character] - room_typeclass = settings.BASE_ROOM_TYPECLASS try: limbo_obj = ObjectDB.objects.get(id=2) diff --git a/evennia/server/server.py b/evennia/server/server.py index 8407ff118..c5bd6da95 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -647,9 +647,8 @@ class Evennia: for guest in AccountDB.objects.all().filter( db_typeclass_path=settings.BASE_GUEST_TYPECLASS ): - for character in guest.db._playable_characters: - if character: - character.delete() + for character in guest.characters: + character.delete() guest.delete() for mod in SERVER_STARTSTOP_MODULES: if hasattr(mod, "at_server_cold_start"): diff --git a/evennia/web/admin/objects.py b/evennia/web/admin/objects.py index 1545add8e..a705fa458 100644 --- a/evennia/web/admin/objects.py +++ b/evennia/web/admin/objects.py @@ -310,7 +310,7 @@ class ObjectAdmin(admin.ModelAdmin): This will: - Set account.db._last_puppet to this object - - Add object to account.db._playable_characters + - Add object to account.characters - Change object locks to allow puppeting by account """ @@ -319,10 +319,7 @@ class ObjectAdmin(admin.ModelAdmin): if account: account.db._last_puppet = obj - if not account.db._playable_characters: - account.db._playable_characters = [] - if obj not in account.db._playable_characters: - account.db._playable_characters.append(obj) + account.add_character(obj) if not obj.access(account, "puppet"): lock = obj.locks.get("puppet") lock += f" or pid({account.id})" @@ -331,7 +328,7 @@ class ObjectAdmin(admin.ModelAdmin): request, "Did the following (where possible): " f"Set Account.db._last_puppet = {obj}, " - f"Added {obj} to Account.db._playable_characters list, " + f"Added {obj} to Account.characters list, " f"Added 'puppet:pid({account.id})' lock to {obj}.", ) else: diff --git a/evennia/web/website/views/help.py b/evennia/web/website/views/help.py index 677328d57..c416f4e73 100644 --- a/evennia/web/website/views/help.py +++ b/evennia/web/website/views/help.py @@ -102,7 +102,7 @@ def collect_topics(account): cmd_help_topics = [] if not str(account) == "AnonymousUser": # create list of account and account's puppets - puppets = account.db._playable_characters + [account] + puppets = account.characters + [account] # add the account's and puppets' commands to cmd_help_topics list for puppet in puppets: for cmdset in puppet.cmdset.get(): From 4b80b200d802b94edd6fb1191e69a827d87eb86e Mon Sep 17 00:00:00 2001 From: Andrew Bastien Date: Sun, 7 May 2023 21:27:33 -0400 Subject: [PATCH 2/6] Cleaned up tests to use newly-renamed Account hooks for add/remove characters. --- .../Part3/Beginner-Tutorial-Chargen.md | 2 +- .../source/Howtos/Turn-based-Combat-System.md | 15 +- .../source/Howtos/Web-Character-Generation.md | 4 +- .../source/Howtos/Web-Help-System-Tutorial.md | 4 +- evennia/__init__.py | 4 + evennia/accounts/accounts.py | 14 +- evennia/accounts/tests.py | 25 +- evennia/commands/default/account.py | 4 +- evennia/commands/default/tests.py | 8 +- evennia/commands/default/unloggedin.py | 2 +- .../character_creator/character_creator.py | 2 +- .../contrib/rpg/character_creator/tests.py | 2 +- .../contrib/tutorials/evadventure/chargen.py | 2 +- evennia/objects/objects.py | 4 +- evennia/server/serversession.py | 62 ++ evennia/utils/ansi.py | 10 + evennia/utils/evrich.py | 635 ++++++++++++++++++ evennia/web/admin/objects.py | 2 +- evennia/web/website/tests.py | 22 +- pyproject.toml | 1 + 20 files changed, 774 insertions(+), 50 deletions(-) create mode 100644 evennia/utils/evrich.py diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Chargen.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Chargen.md index 98ff3d0e8..9523bebaa 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Chargen.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Chargen.md @@ -622,7 +622,7 @@ node_apply_character(caller, raw_string, **kwargs): tmp_character = kwargs["tmp_character"] new_character = tmp_character.apply(caller) - caller.account.db._playable_characters = [new_character] + caller.account.add_character_to_playable_list(new_character) text = "Character created!" diff --git a/docs/source/Howtos/Turn-based-Combat-System.md b/docs/source/Howtos/Turn-based-Combat-System.md index 3c90489fc..e823dee2d 100644 --- a/docs/source/Howtos/Turn-based-Combat-System.md +++ b/docs/source/Howtos/Turn-based-Combat-System.md @@ -311,12 +311,12 @@ Our rock-paper-scissor setup works like this: - `defend` does nothing but has a chance to beat `hit`. - `flee/disengage` must succeed two times in a row (i.e. not beaten by a `hit` once during the turn). If so the character leaves combat. - ```python # mygame/world/rules.py import random + # messages def resolve_combat(combat_handler, actiondict): @@ -326,7 +326,7 @@ def resolve_combat(combat_handler, actiondict): for each character: {char.id:[(action1, char, target), (action2, char, target)], ...} """ - flee = {} # track number of flee commands per character + flee = {} # track number of flee commands per character for isub in range(2): # loop over sub-turns messages = [] @@ -389,7 +389,7 @@ def resolve_combat(combat_handler, actiondict): for (char, fleevalue) in flee.items(): if fleevalue == 2: combat_handler.msg_all(f"{char} withdraws from combat.") - combat_handler.remove_character(char) + combat_handler.remove_character_from_playable_list(char) ``` To make it simple (and to save space), this example rule module actually resolves each interchange twice - first when it gets to each character and then again when handling the target. Also, since we use the combat handler's `msg_all` method here, the system will get pretty spammy. To clean it up, one could imagine tracking all the possible interactions to make sure each pair is only handled and reported once. @@ -403,6 +403,7 @@ This is the last component we need, a command to initiate combat. This will tie from evennia import create_script + class CmdAttack(Command): """ initiates combat @@ -419,7 +420,7 @@ class CmdAttack(Command): def func(self): "Handle command" if not self.args: - self.caller.msg("Usage: attack ") + self.caller.msg("Usage: attack ") return target = self.caller.search(self.args) if not target: @@ -427,13 +428,13 @@ class CmdAttack(Command): # set up combat if target.ndb.combat_handler: # target is already in combat - join it - target.ndb.combat_handler.add_character(self.caller) + target.ndb.combat_handler.add_character_to_playable_list(self.caller) target.ndb.combat_handler.msg_all(f"{self.caller} joins combat!") else: # create a new combat handler chandler = create_script("combat_handler.CombatHandler") - chandler.add_character(self.caller) - chandler.add_character(target) + chandler.add_character_to_playable_list(self.caller) + chandler.add_character_to_playable_list(target) self.caller.msg(f"You attack {target}! You are in combat.") target.msg(f"{self.caller} attacks you! You are in combat.") ``` diff --git a/docs/source/Howtos/Web-Character-Generation.md b/docs/source/Howtos/Web-Character-Generation.md index caf30443d..17680aab5 100644 --- a/docs/source/Howtos/Web-Character-Generation.md +++ b/docs/source/Howtos/Web-Character-Generation.md @@ -206,7 +206,7 @@ def creating(request): # create the character char = create.create_object(typeclass=typeclass, key=name, home=home, permissions=perms) - user.db._playable_characters.append(char) + user.add_character_to_playable_list(char) # add the right locks for the character so the account can # puppet it char.locks.add(" or ".join([ @@ -290,7 +290,7 @@ def creating(request): # create the character char = create.create_object(typeclass=typeclass, key=name, home=home, permissions=perms) - user.db._playable_characters.append(char) + user.add_character_to_playable_list(char) # add the right locks for the character so the account can # puppet it char.locks.add(" or ".join([ diff --git a/docs/source/Howtos/Web-Help-System-Tutorial.md b/docs/source/Howtos/Web-Help-System-Tutorial.md index 1864dc22b..6afbede9c 100644 --- a/docs/source/Howtos/Web-Help-System-Tutorial.md +++ b/docs/source/Howtos/Web-Help-System-Tutorial.md @@ -198,8 +198,8 @@ def index(request): def index(request): """The 'index' view.""" user = request.user - if not user.is_anonymous() and user.db._playable_characters: - character = user.db._playable_characters[0] + if not user.is_anonymous() and user.characters: + character = user.characters[0] ``` In this second case, it will select the first character of the account. diff --git a/evennia/__init__.py b/evennia/__init__.py index d1aac45bb..bdebf67f6 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -186,6 +186,7 @@ def _init(portal_mode=False): from .typeclasses.tags import TagCategoryProperty, TagProperty from .utils import ansi, gametime, logger from .utils.ansi import ANSIString + from .utils.evrich import install as install_evrich # containers from .utils.containers import GLOBAL_SCRIPTS, OPTION_CLASSES @@ -375,6 +376,9 @@ def _init(portal_mode=False): del SystemCmds del _EvContainer + # Trigger EvRich to monkey-patch Rich in-memory. + install_evrich() + # delayed starts - important so as to not back-access evennia before it has # finished initializing if not portal_mode: diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index d0608adf7..bc14b5bf0 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -236,15 +236,15 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): return objs - def add_character(self, character: "DefaultCharacter"): + def add_character_to_playable_list(self, character: "DefaultCharacter"): """ Add a character to this account's list of playable characters. """ if character not in self.db._playable_characters: self.db._playable_characters.append(character) - self.at_post_add_character(character) + self.at_post_add_character_to_playable_list(character) - def at_post_add_character(self, character: "DefaultCharacter"): + def at_post_add_character_to_playable_list(self, character: "DefaultCharacter"): """ Called after a character is added to this account's list of playable characters. @@ -252,15 +252,15 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): """ pass - def remove_character(self, character): + def remove_character_from_playable_list(self, character): """ Remove a character from this account's list of playable characters. """ if character in self.db._playable_characters: self.db._playable_characters.remove(character) - self.at_post_remove_character(character) + self.at_post_remove_character_from_playable_list(character) - def at_post_remove_character(self, character): + def at_post_remove_character_from_playable_list(self, character): """ Called after a character is removed from this account's list of playable characters. @@ -776,7 +776,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): ) if character: # Update playable character list - self.add_character(character) + self.add_character_to_playable_list(character) # We need to set this to have @ic auto-connect to this character self.db._last_puppet = character diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 384f9f882..c35609589 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -105,14 +105,14 @@ class TestDefaultGuest(BaseEvenniaTest): def test_at_server_shutdown(self): account, errors = DefaultGuest.create(ip=self.ip) self.char1.delete = MagicMock() - account.db._playable_characters = [self.char1] + account.add_character_to_playable_list(self.char1) account.at_server_shutdown() self.char1.delete.assert_called() def test_at_post_disconnect(self): account, errors = DefaultGuest.create(ip=self.ip) self.char1.delete = MagicMock() - account.db._playable_characters = [self.char1] + account.add_character_to_playable_list(self.char1) account.at_post_disconnect() self.char1.delete.assert_called() @@ -358,19 +358,19 @@ class TestAccountPuppetDeletion(BaseEvenniaTest): def test_puppet_deletion(self): # Check for existing chars self.assertFalse( - self.account.db._playable_characters, "Account should not have any chars by default." + self.account.characters, "Account should not have any chars by default." ) # Add char1 to account's playable characters - self.account.db._playable_characters.append(self.char1) - self.assertTrue(self.account.db._playable_characters, "Char was not added to account.") + self.account.add_character_to_playable_list(self.char1) + self.assertTrue(self.account.characters, "Char was not added to account.") # See what happens when we delete char1. self.char1.delete() # Playable char list should be empty. self.assertFalse( - self.account.db._playable_characters, - f"Playable character list is not empty! {self.account.db._playable_characters}", + self.account.characters, + f"Playable character list is not empty! {self.account.characters}", ) @@ -387,6 +387,17 @@ class TestDefaultAccountEv(BaseEvenniaTest): self.assertEqual(chars, [self.char1]) self.assertEqual(self.account.db._playable_characters, [self.char1]) + def test_add_character_to_playable_list(self): + self.assertEqual(self.account.characters, []) + self.account.add_character_to_playable_list(self.char1) + self.assertEqual(self.account.characters, [self.char1]) + + def test_remove_character_from_playable_list(self): + self.account.add_character_to_playable_list(self.char1) + self.assertEqual(self.account.characters, [self.char1]) + self.account.remove_character_from_playable_list(self.char1) + self.assertEqual(self.account.characters, []) + def test_puppet_success(self): self.account.msg = MagicMock() with patch("evennia.accounts.accounts._MULTISESSION_MODE", 2): diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index 5e3f2f418..571d9f7ee 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -179,7 +179,7 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS): "puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer);delete:id(%i) or" " perm(Admin)" % (new_character.id, account.id, account.id) ) - account.add_character(new_character) + account.add_character_to_playable_list(new_character) if desc: new_character.db.desc = desc elif not new_character.db.desc: @@ -238,7 +238,7 @@ class CmdCharDelete(COMMAND_DEFAULT_CLASS): # only take action delobj = caller.ndb._char_to_delete key = delobj.key - caller.remove_character(delobj) + caller.remove_character_from_playable_list(delobj) delobj.delete() self.msg(f"Character '{key}' was permanently deleted.") logger.log_sec( diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index c499e6ada..0e25737e8 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -589,7 +589,7 @@ class TestAccount(BaseEvenniaCommandTest): ] ) def test_ooc_look(self, multisession_mode, auto_puppet, max_nr_chars, expected_result): - self.account.db._playable_characters = [self.char1] + self.account.add_character_to_playable_list(self.char1) self.account.unpuppet_all() with self.settings(MULTISESSION=multisession_mode): @@ -609,14 +609,14 @@ class TestAccount(BaseEvenniaCommandTest): self.call(account.CmdOOC(), "", "You go OOC.", caller=self.account) def test_ic(self): - self.account.db._playable_characters = [self.char1] + self.account.add_character_to_playable_list(self.char1) self.account.unpuppet_object(self.session) self.call( account.CmdIC(), "Char", "You become Char.", caller=self.account, receiver=self.char1 ) def test_ic__other_object(self): - self.account.db._playable_characters = [self.obj1] + self.account.add_character_to_playable_list(self.obj1) self.account.unpuppet_object(self.session) self.call( account.CmdIC(), "Obj", "You become Obj.", caller=self.account, receiver=self.obj1 @@ -670,7 +670,7 @@ class TestAccount(BaseEvenniaCommandTest): # whether permissions are being checked # Add char to account playable characters - self.account.db._playable_characters.append(self.char1) + self.account.add_character_to_playable_list(self.char1) # Try deleting as Developer self.call( diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index 0b0179b3d..c4b7c726a 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -507,7 +507,7 @@ def _create_character(session, new_account, typeclass, home, permissions): typeclass, key=new_account.key, home=home, permissions=permissions ) # set playable character list - new_account.add_character(new_character) + new_account.add_character_to_playable_list(new_character) # allow only the character itself and the account to puppet this character (and Developers). new_character.locks.add( diff --git a/evennia/contrib/rpg/character_creator/character_creator.py b/evennia/contrib/rpg/character_creator/character_creator.py index d70a6be0a..b6c0ae51e 100644 --- a/evennia/contrib/rpg/character_creator/character_creator.py +++ b/evennia/contrib/rpg/character_creator/character_creator.py @@ -90,7 +90,7 @@ class ContribCmdCharCreate(MuxAccountCommand): ) # initalize the new character to the beginning of the chargen menu new_character.db.chargen_step = "menunode_welcome" - account.add_character(new_character) + account.add_character_to_playable_list(new_character) # set the menu node to start at to the character's last saved step startnode = new_character.db.chargen_step diff --git a/evennia/contrib/rpg/character_creator/tests.py b/evennia/contrib/rpg/character_creator/tests.py index 4ff180a3d..e6aec7fd9 100644 --- a/evennia/contrib/rpg/character_creator/tests.py +++ b/evennia/contrib/rpg/character_creator/tests.py @@ -17,7 +17,7 @@ class TestCharacterCreator(BaseEvenniaCommandTest): self.account.swap_typeclass(character_creator.ContribChargenAccount) def test_ooc_look(self): - self.account.db._playable_characters = [self.char1] + self.account.add_character_to_playable_list(self.char1) self.account.unpuppet_all() self.char1.db.chargen_step = "start" diff --git a/evennia/contrib/tutorials/evadventure/chargen.py b/evennia/contrib/tutorials/evadventure/chargen.py index 2236ddd7f..fed7b1c76 100644 --- a/evennia/contrib/tutorials/evadventure/chargen.py +++ b/evennia/contrib/tutorials/evadventure/chargen.py @@ -316,7 +316,7 @@ def node_apply_character(caller, raw_string, **kwargs): """ tmp_character = kwargs["tmp_character"] new_character = tmp_character.apply(caller) - caller.add_character(new_character) + caller.add_character_to_playable_list(new_character) text = "Character created!" diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 5e22cc03b..4b6605efa 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -1149,7 +1149,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): # sever the connection (important!) if self.account: # Remove the object from playable characters list - self.account.remove_character(self) + self.account.remove_character_from_playable_list(self) for session in self.sessions.all(): self.account.unpuppet_object(session) @@ -2559,7 +2559,7 @@ class DefaultCharacter(DefaultObject): obj.db.creator_ip = ip if account: obj.db.creator_id = account.id - account.add_character(obj) + account.add_character_to_playable_list(obj) # Add locks if not locks and account: diff --git a/evennia/server/serversession.py b/evennia/server/serversession.py index 9eeb186a2..cf8d3acc6 100644 --- a/evennia/server/serversession.py +++ b/evennia/server/serversession.py @@ -16,6 +16,8 @@ from evennia.scripts.monitorhandler import MONITOR_HANDLER from evennia.typeclasses.attributes import AttributeHandler, DbHolder, InMemoryAttributeBackend from evennia.utils import logger from evennia.utils.utils import class_from_module, lazy_property, make_iter +from evennia.utils.evrich import MudConsole, MudConsoleOptions +from rich.color import ColorSystem _GA = object.__getattribute__ _SA = object.__setattr__ @@ -51,6 +53,57 @@ class ServerSession(_BASE_SESSION_CLASS): self.cmdset_storage_string = "" self.cmdset = CmdSetHandler(self, True) + @lazy_property + def console(self): + from mudrich import MudConsole + if "SCREENWIDTH" in self.protocol_flags: + width = self.protocol_flags["SCREENWIDTH"][0] + else: + width = 78 + return MudConsole(color_system=self.rich_color_system(), width=width, + file=self, record=True) + + def rich_color_system(self): + if self.protocol_flags.get("NOCOLOR", False): + return None + if self.protocol_flags.get("XTERM256", False): + return "256" + if self.protocol_flags.get("ANSI", False): + return "standard" + return None + + def update_rich(self): + check = self.console + if "SCREENWIDTH" in self.protocol_flags: + self.console._width = self.protocol_flags["SCREENWIDTH"][0] + else: + self.console._width = 80 + if self.protocol_flags.get("NOCOLOR", False): + self.console._color_system = None + elif self.protocol_flags.get("XTERM256", False): + self.console._color_system = ColorSystem.EIGHT_BIT + elif self.protocol_flags.get("ANSI", False): + self.console._color_system = ColorSystem.STANDARD + + def write(self, b: str): + """ + When self.console.print() is called, it writes output to here. + Not necessarily useful, but it ensures console print doesn't end up sent out stdout or etc. + """ + + def flush(self): + """ + Do not remove this method. It's needed to trick Console into treating this object + as a file. + """ + + def print(self, *args, **kwargs) -> str: + """ + A thin wrapper around Rich.Console's print. Returns the exported data. + """ + self.console.print(*args, highlight=False, **kwargs) + return self.console.export_text(clear=True, styles=True) + def __cmdset_storage_get(self): return [path.strip() for path in self.cmdset_storage_string.split(",")] @@ -257,6 +310,9 @@ class ServerSession(_BASE_SESSION_CLASS): for the protocol(s). """ + if (t := kwargs.get("text", None)): + if hasattr(t, "__rich_console__"): + kwargs["text"] = self.print(t) self.sessionhandler.data_out(self, **kwargs) def data_in(self, **kwargs): @@ -293,6 +349,8 @@ class ServerSession(_BASE_SESSION_CLASS): kwargs.pop("session", None) kwargs.pop("from_obj", None) if text is not None: + if hasattr(text, "__rich_console__"): + text = self.print(text) self.data_out(text=text, **kwargs) else: self.data_out(**kwargs) @@ -444,3 +502,7 @@ class ServerSession(_BASE_SESSION_CLASS): return self.account.get_display_name(*args, **kwargs) else: return f"{self.protocol_key}({self.address})" + + def load_sync_data(self, sessdata): + super().load_sync_data(sessdata) + self.update_rich() diff --git a/evennia/utils/ansi.py b/evennia/utils/ansi.py index cfa719247..ff2c2cdd6 100644 --- a/evennia/utils/ansi.py +++ b/evennia/utils/ansi.py @@ -72,6 +72,9 @@ from evennia.utils.utils import to_str MXP_ENABLED = settings.MXP_ENABLED +from rich.ansi import AnsiDecoder +from .evrich import MudText + # ANSI definitions @@ -1054,6 +1057,13 @@ class ANSIString(str, metaclass=ANSIMeta): result += self._raw_string[index] return ANSIString(result + clean + append_tail, decoded=True) + def __rich_console__(self, console, options): + """ + Implements the Rich console API, allowing AnsiStrings to be + converted to MudText instances. + """ + yield MudText("\n").join(AnsiDecoder().decode(self)) + def clean(self): """ Return a string object *without* the ANSI escapes. diff --git a/evennia/utils/evrich.py b/evennia/utils/evrich.py new file mode 100644 index 000000000..85fc15cba --- /dev/null +++ b/evennia/utils/evrich.py @@ -0,0 +1,635 @@ +""" +This module installs monkey patches to Rich, allowing it to support MXP. + +MudRich system, by Volund, ported the hard way to Evennia. +""" +import html +from dataclasses import dataclass +import random +import re +from marshal import loads, dumps + +from typing import Any, Dict, Iterable, List, Optional, Type, Union, Tuple + +from rich.color import Color, ColorSystem + +from rich.style import Style as OLD_STYLE +from rich.text import Text as OLD_TEXT, Segment, Span +from rich.console import Console as OLD_CONSOLE, ConsoleOptions as OLD_CONSOLE_OPTIONS, NoChange, NO_CHANGE +from rich.console import JustifyMethod, OverflowMethod + + +_RE_SQUISH = re.compile("\S+") +_RE_NOTSPACE = re.compile("[^ ]+") + + +class MudStyle(OLD_STYLE): + _tag: str + + __slots__ = [ + "_tag", + "_xml_attr", + "_xml_attr_data" + ] + + def __init__( + self, + *, + color: Optional[Union[Color, str]] = None, + bgcolor: Optional[Union[Color, str]] = None, + bold: Optional[bool] = None, + dim: Optional[bool] = None, + italic: Optional[bool] = None, + underline: Optional[bool] = None, + blink: Optional[bool] = None, + blink2: Optional[bool] = None, + reverse: Optional[bool] = None, + conceal: Optional[bool] = None, + strike: Optional[bool] = None, + underline2: Optional[bool] = None, + frame: Optional[bool] = None, + encircle: Optional[bool] = None, + overline: Optional[bool] = None, + link: Optional[str] = None, + meta: Optional[Dict[str, Any]] = None, + tag: Optional[str] = None, + xml_attr: Optional[Dict] = None, + ): + super().__init__(color=color, bgcolor=bgcolor, bold=bold, dim=dim, italic=italic, + underline=underline, blink=blink, blink2=blink2, reverse=reverse, + conceal=conceal, strike=strike, underline2=underline2, frame=frame, + encircle=encircle, overline=overline, link=link, meta=meta) + + self._tag = tag + self._xml_attr = xml_attr + if self._xml_attr: + self._xml_attr_data = ( + " ".join(f'{k}="{html.escape(v)}"' for k, v in xml_attr.items()) + if xml_attr + else "" + ) + else: + self._xml_attr_data = "" + + self._hash = hash( + ( + self._color, + self._bgcolor, + self._attributes, + self._set_attributes, + link, + self._meta, + tag, + self._xml_attr_data + ) + ) + + self._null = not (self._set_attributes or color or bgcolor or link or meta or tag) + + @classmethod + def upgrade(cls, old): + return cls.parse(str(old)) + + def render( + self, + text: str = "", + *, + color_system: Optional[ColorSystem] = ColorSystem.TRUECOLOR, + legacy_windows: bool = False, + mxp: bool = False, + pueblo: bool = False, + links: bool = True, + ) -> str: + """Render the ANSI codes for the style. + + Args: + text (str, optional): A string to style. Defaults to "". + color_system (Optional[ColorSystem], optional): Color system to render to. Defaults to ColorSystem.TRUECOLOR. + + Returns: + str: A string containing ANSI style codes. + """ + out_text = text + if mxp: + out_text = html.escape(out_text) + if not out_text: + return out_text + if color_system is not None: + attrs = self._make_ansi_codes(color_system) + rendered = f"\x1b[{attrs}m{out_text}\x1b[0m" if attrs else out_text + else: + rendered = out_text + if links and self._link and not legacy_windows: + rendered = ( + f"\x1b]8;id={self._link_id};{self._link}\x1b\\{rendered}\x1b]8;;\x1b\\" + ) + if (pueblo or mxp) and self._tag: + if mxp: + if self._xml_attr: + rendered = f"\x1b[4z<{self._tag} {self._xml_attr_data}>{rendered}\x1b[4z" + else: + rendered = f"\x1b[4z<{self._tag}>{rendered}\x1b[4z" + else: + if self._xml_attr: + rendered = ( + f"{self._tag} {self._xml_attr_data}>{rendered}" + ) + else: + rendered = f"<{self._tag}>{rendered}" + return rendered + + def __add__(self, style: Union["Style", str]) -> "Style": + if isinstance(style, str): + style = self.__class__.parse(style) + if not (isinstance(style, MudStyle) or style is None): + return NotImplemented + if style is None or style._null: + return self + if self._null: + return style + new_style: MudStyle = self.__new__(MudStyle) + new_style._ansi = None + new_style._style_definition = None + new_style._color = style._color or self._color + new_style._bgcolor = style._bgcolor or self._bgcolor + new_style._attributes = (self._attributes & ~style._set_attributes) | ( + style._attributes & style._set_attributes + ) + new_style._set_attributes = self._set_attributes | style._set_attributes + new_style._link = style._link or self._link + new_style._link_id = style._link_id or self._link_id + + new_style._tag = None + if hasattr(style, "_tag") and hasattr(self, "_tag"): + new_style._tag = style._tag or self._tag + + new_style._xml_attr = None + if hasattr(style, "_xml_attr") and hasattr(self, "_xml_attr"): + new_style._xml_attr = style._xml_attr or self._xml_attr + + new_style._xml_attr_data = "" + if hasattr(style, "_xml_attr_data") and hasattr(self, "_xml_attr_data"): + new_style._xml_attr_data = style._xml_attr_data or self._xml_attr_data + + new_style._hash = style._hash + new_style._null = self._null or style._null + if self._meta and style._meta: + new_style._meta = dumps({**self.meta, **style.meta}) + else: + new_style._meta = self._meta or style._meta + + return new_style + + def __radd__(self, other): + if isinstance(other, str): + other = self.__class__.parse(other) + return other + self + return NotImplemented + + +@dataclass +class MudConsoleOptions(OLD_CONSOLE_OPTIONS): + mxp: Optional[bool] = False + """Enable MXP/MUD HTML when printing. For MUDs only.""" + pueblo: Optional[bool] = False + """Enable Pueblo/MUD HTML when printing. For MUDs only.""" + links: Optional[bool] = True + """Enable ANSI Links when printing. Turn off if MXP/Pueblo is on.""" + + def update( + self, + *, + width: Union[int, NoChange] = NO_CHANGE, + min_width: Union[int, NoChange] = NO_CHANGE, + max_width: Union[int, NoChange] = NO_CHANGE, + justify: Union[Optional[JustifyMethod], NoChange] = NO_CHANGE, + overflow: Union[Optional[OverflowMethod], NoChange] = NO_CHANGE, + no_wrap: Union[Optional[bool], NoChange] = NO_CHANGE, + highlight: Union[Optional[bool], NoChange] = NO_CHANGE, + markup: Union[Optional[bool], NoChange] = NO_CHANGE, + height: Union[Optional[int], NoChange] = NO_CHANGE, + mxp: Union[Optional[bool], NoChange] = NO_CHANGE, + pueblo: Union[Optional[bool], NoChange] = NO_CHANGE, + links: Union[Optional[bool], NoChange] = NO_CHANGE, + ) -> "ConsoleOptions": + """Update values, return a copy.""" + options = self.copy() + if not isinstance(width, NoChange): + options.min_width = options.max_width = max(0, width) + if not isinstance(min_width, NoChange): + options.min_width = min_width + if not isinstance(max_width, NoChange): + options.max_width = max_width + if not isinstance(justify, NoChange): + options.justify = justify + if not isinstance(overflow, NoChange): + options.overflow = overflow + if not isinstance(no_wrap, NoChange): + options.no_wrap = no_wrap + if not isinstance(highlight, NoChange): + options.highlight = highlight + if not isinstance(markup, NoChange): + options.markup = markup + if not isinstance(height, NoChange): + options.height = None if height is None else max(0, height) + if not isinstance(mxp, NoChange): + options.mxp = mxp + if not isinstance(pueblo, NoChange): + options.pueblo = pueblo + if not isinstance(links, NoChange): + options.links = links + return options + + +class MudConsole(OLD_CONSOLE): + + def __init__(self, **kwargs): + mxp = kwargs.pop("mxp", False) + pueblo = kwargs.pop("pueblo", False) + links = kwargs.pop("links", False) + super().__init__(**kwargs) + + self._mxp = mxp + self._pueblo = pueblo + self._links = links + + def export_text(self, *, clear: bool = True, styles: bool = False) -> str: + """Generate text from console contents (requires record=True argument in constructor). + Args: + clear (bool, optional): Clear record buffer after exporting. Defaults to ``True``. + styles (bool, optional): If ``True``, ansi escape codes will be included. ``False`` for plain text. + Defaults to ``False``. + Returns: + str: String containing console contents. + """ + assert ( + self.record + ), "To export console contents set record=True in the constructor or instance" + + with self._record_buffer_lock: + if styles: + text = "".join( + (style.render( + text, + color_system=self.color_system, + legacy_windows=self.legacy_windows, + mxp=self._mxp, + pueblo=self._pueblo, + links=self._links, + ) if style else text) + for text, style, _ in self._record_buffer + ) + else: + text = "".join( + segment.text + for segment in self._record_buffer + if not segment.control + ) + if clear: + del self._record_buffer[:] + return text + + def _render_buffer(self, buffer: Iterable[Segment]) -> str: + """Render buffered output, and clear buffer.""" + output: List[str] = [] + append = output.append + color_system = self._color_system + legacy_windows = self.legacy_windows + not_terminal = not self.is_terminal + if self.no_color and color_system: + buffer = Segment.remove_color(buffer) + for text, style, control in buffer: + if style: + append( + style.render( + text, + color_system=color_system, + legacy_windows=legacy_windows, + mxp=self._mxp, + pueblo=self._pueblo, + links=self._links, + ) + ) + elif not (not_terminal and control): + append(text) + + rendered = "".join(output) + return rendered + + +class MudText(OLD_TEXT): + + def __radd__(self, other): + if isinstance(other, str): + other = self.__class__(text=other) + return other + self + return NotImplemented + + def __iadd__(self, other: Any) -> "Text": + if isinstance(other, (str, OLD_TEXT)): + self.append(other) + return self + return NotImplemented + + def __mul__(self, other): + if not isinstance(other, int): + return self + if other <= 0: + return self.__class__() + if other == 1: + return self.copy() + if other > 1: + out = self.copy() + for i in range(other - 1): + out.append(self) + return out + + def __rmul__(self, other): + if not isinstance(other, int): + return self + return self * other + + def __format__(self, format_spec): + """ + Allows use of f-strings, although styling is not preserved. + """ + return self.plain.__format__(format_spec) + + # Begin implementing Python String Api below... + + def capitalize(self): + return self.__class__(text=self.plain.capitalize(), style=self.style, spans=list(self.spans)) + + def count(self, *args, **kwargs): + return self.plain.count(*args, **kwargs) + + def startswith(self, *args, **kwargs): + return self.plain.startswith(*args, **kwargs) + + def endswith(self, *args, **kwargs): + return self.plain.endswith(*args, **kwargs) + + def find(self, *args, **kwargs): + return self.plain.find(*args, **kwargs) + + def index(self, *args, **kwargs): + return self.plain.index(*args, **kwargs) + + def isalnum(self): + return self.plain.isalnum() + + def isalpha(self): + return self.plain.isalpha() + + def isdecimal(self): + return self.plain.isdecimal() + + def isdigit(self): + return self.plain.isdigit() + + def isidentifier(self): + return self.plain.isidentifier() + + def islower(self): + return self.plain.islower() + + def isnumeric(self): + return self.plain.isnumeric() + + def isprintable(self): + return self.plain.isprintable() + + def isspace(self): + return self.plain.isspace() + + def istitle(self): + return self.plain.istitle() + + def isupper(self): + return self.plain.isupper() + + def center(self, width, fillchar=" "): + changed = self.plain.center(width, fillchar) + start = changed.find(self.plain) + lside = changed[:start] + rside = changed[len(lside) + len(self.plain):] + idx = self.disassemble_bits() + new_idx = list() + for c in lside: + new_idx.append((None, c)) + new_idx.extend(idx) + for c in rside: + new_idx.append((None, c)) + return self.__class__.assemble_bits(new_idx) + + def ljust(self, width: int, fillchar: Union[str, "MudText"] = " "): + diff = width - len(self) + out = self.copy() + if diff <= 0: + return out + else: + if isinstance(fillchar, str): + fillchar = self.__class__(fillchar) + out.append(fillchar * diff) + return out + + def rjust(self, width: int, fillchar: Union[str, "MudText"] = " "): + diff = width - len(self) + if diff <= 0: + return self.copy() + else: + if isinstance(fillchar, str): + fillchar = self.__class__(fillchar) + out = fillchar * diff + out.append(self) + return out + + def lstrip(self, chars: str = None): + lstripped = self.plain.lstrip(chars) + strip_count = len(self.plain) - len(lstripped) + return self[strip_count:] + + def strip(self, chars: str = " "): + out_map = self.disassemble_bits() + for i, e in enumerate(out_map): + if e[1] != chars: + out_map = out_map[i:] + break + out_map.reverse() + for i, e in enumerate(out_map): + if e[1] != chars: + out_map = out_map[i:] + break + out_map.reverse() + return self.__class__.assemble_bits(out_map) + + def replace(self, old: str, new: Union[str, "Text"], count=None) -> "Text": + if not (indexes := self.find_all(old)): + return self.clone() + if count and count > 0: + indexes = indexes[:count] + old_len = len(old) + new_len = len(new) + other = self.clone() + markup_idx_map = self.disassemble_bits() + other_map = other.disassemble_bits() + + for idx in reversed(indexes): + final_markup = markup_idx_map[idx + old_len][0] + diff = abs(old_len - new_len) + replace_chars = min(new_len, old_len) + # First, replace any characters that overlap. + for i in range(replace_chars): + other_map[idx + i] = (markup_idx_map[idx + i][0], new[i]) + if old_len == new_len: + pass # the nicest case. nothing else needs doing. + elif old_len > new_len: + # slightly complex. pop off remaining characters. + for i in range(diff): + deleted = other_map.pop(idx + new_len) + elif new_len > old_len: + # slightly complex. insert new characters. + for i in range(diff): + other_map.insert( + idx + old_len + i, (final_markup, new[old_len + i]) + ) + + return self.__class__.assemble_bits(other_map) + + def find_all(self, sub: str): + indexes = list() + start = 0 + while True: + start = self.plain.find(sub, start) + if start == -1: + return indexes + indexes.append(start) + start += len(sub) + + def scramble(self): + idx = self.disassemble_bits() + random.shuffle(idx) + return self.__class__.assemble_bits(idx) + + def reverse(self): + idx = self.disassemble_bits() + idx.reverse() + return self.__class__.assemble_bits(idx) + + @classmethod + def assemble_bits(cls, idx: List[Tuple[Optional[Union[str, MudStyle, None]], str]]): + out = cls() + for i, t in enumerate(idx): + s = [Span(0, 1, t[0])] + out.append_text(cls(text=t[1], spans=s)) + return out + + def style_at_index(self, offset: int) -> MudStyle: + if offset < 0: + offset = len(self) + offset + style = MudStyle.null() + for start, end, span_style in self._spans: + if end > offset >= start: + style = style + span_style + return style + + def disassemble_bits(self) -> List[Tuple[Optional[Union[str, MudStyle, None]], str]]: + idx = list() + for i, c in enumerate(self.plain): + idx.append((self.style_at_index(i), c)) + return idx + + def squish(self) -> "MudText": + """ + Removes leading and trailing whitespace, and coerces all internal whitespace sequences + into at most a single space. Returns the results. + """ + out = list() + matches = _RE_SQUISH.finditer(self.plain) + for match in matches: + out.append(self[match.start(): match.end()]) + return self.__class__(" ").join(out) + + def squish_spaces(self) -> "MudText": + """ + Like squish, but retains newlines and tabs. Just squishes spaces. + """ + out = list() + matches = _RE_NOTSPACE.finditer(self.plain) + for match in matches: + out.append(self[match.start(): match.end()]) + return self.__class__(" ").join(out) + + def serialize(self) -> dict: + def ser_style(style): + if isinstance(style, str): + style = MudStyle.parse(style) + if not isinstance(style, MudStyle): + style = MudStyle.upgrade(style) + return style.serialize() + + def ser_span(span): + if not span.style: + return None + return { + "start": span.start, + "end": span.end, + "style": ser_style(span.style), + } + + out = {"text": self.plain} + + if self.style: + out["style"] = ser_style(self.style) + + out_spans = [s for span in self.spans if (s := ser_span(span))] + + if out_spans: + out["spans"] = out_spans + + return out + + @classmethod + def deserialize(cls, data) -> "Text": + text = data.get("text", None) + if text is None: + return cls("") + style = data.get("style", None) + if style: + style = MudStyle(**style) + + spans = data.get("spans", None) + + if spans: + spans = [Span(s["start"], s["end"], MudStyle(**s["style"])) for s in spans] + + return cls(text=text, style=style, spans=spans) + + +DEFAULT_STYLES = dict() + + +def install(): + from rich import style, text, console, default_styles, themes, syntax, traceback + global DEFAULT_STYLES + style.Style = MudStyle + style.NULL_STYLE = MudStyle() + text.Text = MudText + console.Console = MudConsole + console.ConsoleOptions = MudConsoleOptions + + traceback.Style = MudStyle + syntax.Style = MudStyle + traceback.Text = MudText + syntax.Text = MudText + + for k, v in default_styles.DEFAULT_STYLES.items(): + DEFAULT_STYLES[k] = MudStyle.upgrade(v) + + for theme in syntax.RICH_SYNTAX_THEMES.values(): + for k, v in theme.items(): + if isinstance(v, OLD_STYLE): + theme[k] = MudStyle.upgrade(v) + + default_styles.DEFAULT_STYLES = DEFAULT_STYLES + themes.DEFAULT = themes.Theme(DEFAULT_STYLES) \ No newline at end of file diff --git a/evennia/web/admin/objects.py b/evennia/web/admin/objects.py index a705fa458..da73a4931 100644 --- a/evennia/web/admin/objects.py +++ b/evennia/web/admin/objects.py @@ -319,7 +319,7 @@ class ObjectAdmin(admin.ModelAdmin): if account: account.db._last_puppet = obj - account.add_character(obj) + account.add_character_to_playable_list(obj) if not obj.access(account, "puppet"): lock = obj.locks.get("puppet") lock += f" or pid({account.id})" diff --git a/evennia/web/website/tests.py b/evennia/web/website/tests.py index fc1073a58..fc635c4b1 100644 --- a/evennia/web/website/tests.py +++ b/evennia/web/website/tests.py @@ -35,8 +35,8 @@ class EvenniaWebTest(BaseEvenniaTest): super().setUp() # Add chars to account rosters - self.account.db._playable_characters = [self.char1] - self.account2.db._playable_characters = [self.char2] + self.account.add_character_to_playable_list(self.char1) + self.account2.add_character_to_playable_list(self.char2) for account in (self.account, self.account2): # Demote accounts to Player permissions @@ -44,15 +44,15 @@ class EvenniaWebTest(BaseEvenniaTest): account.permissions.remove("Developer") # Grant permissions to chars - for char in account.db._playable_characters: + for char in account.characters: char.locks.add("edit:id(%s) or perm(Admin)" % account.pk) char.locks.add("delete:id(%s) or perm(Admin)" % account.pk) char.locks.add("view:all()") def test_valid_chars(self): "Make sure account has playable characters" - self.assertTrue(self.char1 in self.account.db._playable_characters) - self.assertTrue(self.char2 in self.account2.db._playable_characters) + self.assertTrue(self.char1 in self.account.characters) + self.assertTrue(self.char2 in self.account2.characters) def get_kwargs(self): return {} @@ -220,7 +220,7 @@ class CharacterCreateView(EvenniaWebTest): @override_settings(MAX_NR_CHARACTERS=1) def test_valid_access_multisession_0(self): "Account1 with no characters should be able to create a new one" - self.account.db._playable_characters = [] + self.assertFalse(self.account.characters, "Account1 has characters but shouldn't!") # Login account self.login() @@ -233,9 +233,9 @@ class CharacterCreateView(EvenniaWebTest): # Make sure the character was actually created self.assertTrue( - len(self.account.db._playable_characters) == 1, + len(self.account.characters) == 1, "Account only has the following characters attributed to it: %s" - % self.account.db._playable_characters, + % self.account.characters, ) @override_settings(MAX_NR_CHARACTERS=5) @@ -252,9 +252,9 @@ class CharacterCreateView(EvenniaWebTest): # Make sure the character was actually created self.assertTrue( - len(self.account.db._playable_characters) > 1, + len(self.account.characters) > 1, "Account only has the following characters attributed to it: %s" - % self.account.db._playable_characters, + % self.account.characters, ) @@ -352,7 +352,7 @@ class CharacterDeleteView(EvenniaWebTest): # Make sure it deleted self.assertFalse( - self.char1 in self.account.db._playable_characters, + self.char1 in self.account.characters, "Char1 is still in Account playable characters list.", ) diff --git a/pyproject.toml b/pyproject.toml index 9c74ce2cf..ab4353a10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,7 @@ dependencies = [ "black >= 22.6", "isort >= 5.10", "parameterized ==0.8.1", + "rich >= 13.3.5, ] [project.optional-dependencies] From 376d1d1ec350329238744a04eb1d3ff1a4f48a42 Mon Sep 17 00:00:00 2001 From: Andrew Bastien Date: Sun, 7 May 2023 21:49:06 -0400 Subject: [PATCH 3/6] Fixed a borked test. --- evennia/web/website/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/web/website/tests.py b/evennia/web/website/tests.py index fc635c4b1..e5b857109 100644 --- a/evennia/web/website/tests.py +++ b/evennia/web/website/tests.py @@ -220,7 +220,7 @@ class CharacterCreateView(EvenniaWebTest): @override_settings(MAX_NR_CHARACTERS=1) def test_valid_access_multisession_0(self): "Account1 with no characters should be able to create a new one" - self.assertFalse(self.account.characters, "Account1 has characters but shouldn't!") + self.account.remove_character_from_playable_list(self.char1) # Login account self.login() From c91822606f8a5b83b7186155d49d54a6910cc2b6 Mon Sep 17 00:00:00 2001 From: Andrew Bastien Date: Sat, 9 Sep 2023 14:13:02 -0400 Subject: [PATCH 4/6] Cleaning up GlobalScriptContainer from some junk logic. --- evennia/__init__.py | 4 - evennia/server/serversession.py | 56 --- evennia/utils/ansi.py | 11 - evennia/utils/evrich.py | 635 -------------------------------- pyproject.toml | 3 +- 5 files changed, 1 insertion(+), 708 deletions(-) delete mode 100644 evennia/utils/evrich.py diff --git a/evennia/__init__.py b/evennia/__init__.py index bdebf67f6..d1aac45bb 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -186,7 +186,6 @@ def _init(portal_mode=False): from .typeclasses.tags import TagCategoryProperty, TagProperty from .utils import ansi, gametime, logger from .utils.ansi import ANSIString - from .utils.evrich import install as install_evrich # containers from .utils.containers import GLOBAL_SCRIPTS, OPTION_CLASSES @@ -376,9 +375,6 @@ def _init(portal_mode=False): del SystemCmds del _EvContainer - # Trigger EvRich to monkey-patch Rich in-memory. - install_evrich() - # delayed starts - important so as to not back-access evennia before it has # finished initializing if not portal_mode: diff --git a/evennia/server/serversession.py b/evennia/server/serversession.py index cf8d3acc6..6f535c898 100644 --- a/evennia/server/serversession.py +++ b/evennia/server/serversession.py @@ -16,8 +16,6 @@ from evennia.scripts.monitorhandler import MONITOR_HANDLER from evennia.typeclasses.attributes import AttributeHandler, DbHolder, InMemoryAttributeBackend from evennia.utils import logger from evennia.utils.utils import class_from_module, lazy_property, make_iter -from evennia.utils.evrich import MudConsole, MudConsoleOptions -from rich.color import ColorSystem _GA = object.__getattribute__ _SA = object.__setattr__ @@ -53,57 +51,6 @@ class ServerSession(_BASE_SESSION_CLASS): self.cmdset_storage_string = "" self.cmdset = CmdSetHandler(self, True) - @lazy_property - def console(self): - from mudrich import MudConsole - if "SCREENWIDTH" in self.protocol_flags: - width = self.protocol_flags["SCREENWIDTH"][0] - else: - width = 78 - return MudConsole(color_system=self.rich_color_system(), width=width, - file=self, record=True) - - def rich_color_system(self): - if self.protocol_flags.get("NOCOLOR", False): - return None - if self.protocol_flags.get("XTERM256", False): - return "256" - if self.protocol_flags.get("ANSI", False): - return "standard" - return None - - def update_rich(self): - check = self.console - if "SCREENWIDTH" in self.protocol_flags: - self.console._width = self.protocol_flags["SCREENWIDTH"][0] - else: - self.console._width = 80 - if self.protocol_flags.get("NOCOLOR", False): - self.console._color_system = None - elif self.protocol_flags.get("XTERM256", False): - self.console._color_system = ColorSystem.EIGHT_BIT - elif self.protocol_flags.get("ANSI", False): - self.console._color_system = ColorSystem.STANDARD - - def write(self, b: str): - """ - When self.console.print() is called, it writes output to here. - Not necessarily useful, but it ensures console print doesn't end up sent out stdout or etc. - """ - - def flush(self): - """ - Do not remove this method. It's needed to trick Console into treating this object - as a file. - """ - - def print(self, *args, **kwargs) -> str: - """ - A thin wrapper around Rich.Console's print. Returns the exported data. - """ - self.console.print(*args, highlight=False, **kwargs) - return self.console.export_text(clear=True, styles=True) - def __cmdset_storage_get(self): return [path.strip() for path in self.cmdset_storage_string.split(",")] @@ -503,6 +450,3 @@ class ServerSession(_BASE_SESSION_CLASS): else: return f"{self.protocol_key}({self.address})" - def load_sync_data(self, sessdata): - super().load_sync_data(sessdata) - self.update_rich() diff --git a/evennia/utils/ansi.py b/evennia/utils/ansi.py index ff2c2cdd6..c68dc1846 100644 --- a/evennia/utils/ansi.py +++ b/evennia/utils/ansi.py @@ -72,10 +72,6 @@ from evennia.utils.utils import to_str MXP_ENABLED = settings.MXP_ENABLED -from rich.ansi import AnsiDecoder -from .evrich import MudText - - # ANSI definitions ANSI_BEEP = "\07" @@ -1057,13 +1053,6 @@ class ANSIString(str, metaclass=ANSIMeta): result += self._raw_string[index] return ANSIString(result + clean + append_tail, decoded=True) - def __rich_console__(self, console, options): - """ - Implements the Rich console API, allowing AnsiStrings to be - converted to MudText instances. - """ - yield MudText("\n").join(AnsiDecoder().decode(self)) - def clean(self): """ Return a string object *without* the ANSI escapes. diff --git a/evennia/utils/evrich.py b/evennia/utils/evrich.py deleted file mode 100644 index 85fc15cba..000000000 --- a/evennia/utils/evrich.py +++ /dev/null @@ -1,635 +0,0 @@ -""" -This module installs monkey patches to Rich, allowing it to support MXP. - -MudRich system, by Volund, ported the hard way to Evennia. -""" -import html -from dataclasses import dataclass -import random -import re -from marshal import loads, dumps - -from typing import Any, Dict, Iterable, List, Optional, Type, Union, Tuple - -from rich.color import Color, ColorSystem - -from rich.style import Style as OLD_STYLE -from rich.text import Text as OLD_TEXT, Segment, Span -from rich.console import Console as OLD_CONSOLE, ConsoleOptions as OLD_CONSOLE_OPTIONS, NoChange, NO_CHANGE -from rich.console import JustifyMethod, OverflowMethod - - -_RE_SQUISH = re.compile("\S+") -_RE_NOTSPACE = re.compile("[^ ]+") - - -class MudStyle(OLD_STYLE): - _tag: str - - __slots__ = [ - "_tag", - "_xml_attr", - "_xml_attr_data" - ] - - def __init__( - self, - *, - color: Optional[Union[Color, str]] = None, - bgcolor: Optional[Union[Color, str]] = None, - bold: Optional[bool] = None, - dim: Optional[bool] = None, - italic: Optional[bool] = None, - underline: Optional[bool] = None, - blink: Optional[bool] = None, - blink2: Optional[bool] = None, - reverse: Optional[bool] = None, - conceal: Optional[bool] = None, - strike: Optional[bool] = None, - underline2: Optional[bool] = None, - frame: Optional[bool] = None, - encircle: Optional[bool] = None, - overline: Optional[bool] = None, - link: Optional[str] = None, - meta: Optional[Dict[str, Any]] = None, - tag: Optional[str] = None, - xml_attr: Optional[Dict] = None, - ): - super().__init__(color=color, bgcolor=bgcolor, bold=bold, dim=dim, italic=italic, - underline=underline, blink=blink, blink2=blink2, reverse=reverse, - conceal=conceal, strike=strike, underline2=underline2, frame=frame, - encircle=encircle, overline=overline, link=link, meta=meta) - - self._tag = tag - self._xml_attr = xml_attr - if self._xml_attr: - self._xml_attr_data = ( - " ".join(f'{k}="{html.escape(v)}"' for k, v in xml_attr.items()) - if xml_attr - else "" - ) - else: - self._xml_attr_data = "" - - self._hash = hash( - ( - self._color, - self._bgcolor, - self._attributes, - self._set_attributes, - link, - self._meta, - tag, - self._xml_attr_data - ) - ) - - self._null = not (self._set_attributes or color or bgcolor or link or meta or tag) - - @classmethod - def upgrade(cls, old): - return cls.parse(str(old)) - - def render( - self, - text: str = "", - *, - color_system: Optional[ColorSystem] = ColorSystem.TRUECOLOR, - legacy_windows: bool = False, - mxp: bool = False, - pueblo: bool = False, - links: bool = True, - ) -> str: - """Render the ANSI codes for the style. - - Args: - text (str, optional): A string to style. Defaults to "". - color_system (Optional[ColorSystem], optional): Color system to render to. Defaults to ColorSystem.TRUECOLOR. - - Returns: - str: A string containing ANSI style codes. - """ - out_text = text - if mxp: - out_text = html.escape(out_text) - if not out_text: - return out_text - if color_system is not None: - attrs = self._make_ansi_codes(color_system) - rendered = f"\x1b[{attrs}m{out_text}\x1b[0m" if attrs else out_text - else: - rendered = out_text - if links and self._link and not legacy_windows: - rendered = ( - f"\x1b]8;id={self._link_id};{self._link}\x1b\\{rendered}\x1b]8;;\x1b\\" - ) - if (pueblo or mxp) and self._tag: - if mxp: - if self._xml_attr: - rendered = f"\x1b[4z<{self._tag} {self._xml_attr_data}>{rendered}\x1b[4z" - else: - rendered = f"\x1b[4z<{self._tag}>{rendered}\x1b[4z" - else: - if self._xml_attr: - rendered = ( - f"{self._tag} {self._xml_attr_data}>{rendered}" - ) - else: - rendered = f"<{self._tag}>{rendered}" - return rendered - - def __add__(self, style: Union["Style", str]) -> "Style": - if isinstance(style, str): - style = self.__class__.parse(style) - if not (isinstance(style, MudStyle) or style is None): - return NotImplemented - if style is None or style._null: - return self - if self._null: - return style - new_style: MudStyle = self.__new__(MudStyle) - new_style._ansi = None - new_style._style_definition = None - new_style._color = style._color or self._color - new_style._bgcolor = style._bgcolor or self._bgcolor - new_style._attributes = (self._attributes & ~style._set_attributes) | ( - style._attributes & style._set_attributes - ) - new_style._set_attributes = self._set_attributes | style._set_attributes - new_style._link = style._link or self._link - new_style._link_id = style._link_id or self._link_id - - new_style._tag = None - if hasattr(style, "_tag") and hasattr(self, "_tag"): - new_style._tag = style._tag or self._tag - - new_style._xml_attr = None - if hasattr(style, "_xml_attr") and hasattr(self, "_xml_attr"): - new_style._xml_attr = style._xml_attr or self._xml_attr - - new_style._xml_attr_data = "" - if hasattr(style, "_xml_attr_data") and hasattr(self, "_xml_attr_data"): - new_style._xml_attr_data = style._xml_attr_data or self._xml_attr_data - - new_style._hash = style._hash - new_style._null = self._null or style._null - if self._meta and style._meta: - new_style._meta = dumps({**self.meta, **style.meta}) - else: - new_style._meta = self._meta or style._meta - - return new_style - - def __radd__(self, other): - if isinstance(other, str): - other = self.__class__.parse(other) - return other + self - return NotImplemented - - -@dataclass -class MudConsoleOptions(OLD_CONSOLE_OPTIONS): - mxp: Optional[bool] = False - """Enable MXP/MUD HTML when printing. For MUDs only.""" - pueblo: Optional[bool] = False - """Enable Pueblo/MUD HTML when printing. For MUDs only.""" - links: Optional[bool] = True - """Enable ANSI Links when printing. Turn off if MXP/Pueblo is on.""" - - def update( - self, - *, - width: Union[int, NoChange] = NO_CHANGE, - min_width: Union[int, NoChange] = NO_CHANGE, - max_width: Union[int, NoChange] = NO_CHANGE, - justify: Union[Optional[JustifyMethod], NoChange] = NO_CHANGE, - overflow: Union[Optional[OverflowMethod], NoChange] = NO_CHANGE, - no_wrap: Union[Optional[bool], NoChange] = NO_CHANGE, - highlight: Union[Optional[bool], NoChange] = NO_CHANGE, - markup: Union[Optional[bool], NoChange] = NO_CHANGE, - height: Union[Optional[int], NoChange] = NO_CHANGE, - mxp: Union[Optional[bool], NoChange] = NO_CHANGE, - pueblo: Union[Optional[bool], NoChange] = NO_CHANGE, - links: Union[Optional[bool], NoChange] = NO_CHANGE, - ) -> "ConsoleOptions": - """Update values, return a copy.""" - options = self.copy() - if not isinstance(width, NoChange): - options.min_width = options.max_width = max(0, width) - if not isinstance(min_width, NoChange): - options.min_width = min_width - if not isinstance(max_width, NoChange): - options.max_width = max_width - if not isinstance(justify, NoChange): - options.justify = justify - if not isinstance(overflow, NoChange): - options.overflow = overflow - if not isinstance(no_wrap, NoChange): - options.no_wrap = no_wrap - if not isinstance(highlight, NoChange): - options.highlight = highlight - if not isinstance(markup, NoChange): - options.markup = markup - if not isinstance(height, NoChange): - options.height = None if height is None else max(0, height) - if not isinstance(mxp, NoChange): - options.mxp = mxp - if not isinstance(pueblo, NoChange): - options.pueblo = pueblo - if not isinstance(links, NoChange): - options.links = links - return options - - -class MudConsole(OLD_CONSOLE): - - def __init__(self, **kwargs): - mxp = kwargs.pop("mxp", False) - pueblo = kwargs.pop("pueblo", False) - links = kwargs.pop("links", False) - super().__init__(**kwargs) - - self._mxp = mxp - self._pueblo = pueblo - self._links = links - - def export_text(self, *, clear: bool = True, styles: bool = False) -> str: - """Generate text from console contents (requires record=True argument in constructor). - Args: - clear (bool, optional): Clear record buffer after exporting. Defaults to ``True``. - styles (bool, optional): If ``True``, ansi escape codes will be included. ``False`` for plain text. - Defaults to ``False``. - Returns: - str: String containing console contents. - """ - assert ( - self.record - ), "To export console contents set record=True in the constructor or instance" - - with self._record_buffer_lock: - if styles: - text = "".join( - (style.render( - text, - color_system=self.color_system, - legacy_windows=self.legacy_windows, - mxp=self._mxp, - pueblo=self._pueblo, - links=self._links, - ) if style else text) - for text, style, _ in self._record_buffer - ) - else: - text = "".join( - segment.text - for segment in self._record_buffer - if not segment.control - ) - if clear: - del self._record_buffer[:] - return text - - def _render_buffer(self, buffer: Iterable[Segment]) -> str: - """Render buffered output, and clear buffer.""" - output: List[str] = [] - append = output.append - color_system = self._color_system - legacy_windows = self.legacy_windows - not_terminal = not self.is_terminal - if self.no_color and color_system: - buffer = Segment.remove_color(buffer) - for text, style, control in buffer: - if style: - append( - style.render( - text, - color_system=color_system, - legacy_windows=legacy_windows, - mxp=self._mxp, - pueblo=self._pueblo, - links=self._links, - ) - ) - elif not (not_terminal and control): - append(text) - - rendered = "".join(output) - return rendered - - -class MudText(OLD_TEXT): - - def __radd__(self, other): - if isinstance(other, str): - other = self.__class__(text=other) - return other + self - return NotImplemented - - def __iadd__(self, other: Any) -> "Text": - if isinstance(other, (str, OLD_TEXT)): - self.append(other) - return self - return NotImplemented - - def __mul__(self, other): - if not isinstance(other, int): - return self - if other <= 0: - return self.__class__() - if other == 1: - return self.copy() - if other > 1: - out = self.copy() - for i in range(other - 1): - out.append(self) - return out - - def __rmul__(self, other): - if not isinstance(other, int): - return self - return self * other - - def __format__(self, format_spec): - """ - Allows use of f-strings, although styling is not preserved. - """ - return self.plain.__format__(format_spec) - - # Begin implementing Python String Api below... - - def capitalize(self): - return self.__class__(text=self.plain.capitalize(), style=self.style, spans=list(self.spans)) - - def count(self, *args, **kwargs): - return self.plain.count(*args, **kwargs) - - def startswith(self, *args, **kwargs): - return self.plain.startswith(*args, **kwargs) - - def endswith(self, *args, **kwargs): - return self.plain.endswith(*args, **kwargs) - - def find(self, *args, **kwargs): - return self.plain.find(*args, **kwargs) - - def index(self, *args, **kwargs): - return self.plain.index(*args, **kwargs) - - def isalnum(self): - return self.plain.isalnum() - - def isalpha(self): - return self.plain.isalpha() - - def isdecimal(self): - return self.plain.isdecimal() - - def isdigit(self): - return self.plain.isdigit() - - def isidentifier(self): - return self.plain.isidentifier() - - def islower(self): - return self.plain.islower() - - def isnumeric(self): - return self.plain.isnumeric() - - def isprintable(self): - return self.plain.isprintable() - - def isspace(self): - return self.plain.isspace() - - def istitle(self): - return self.plain.istitle() - - def isupper(self): - return self.plain.isupper() - - def center(self, width, fillchar=" "): - changed = self.plain.center(width, fillchar) - start = changed.find(self.plain) - lside = changed[:start] - rside = changed[len(lside) + len(self.plain):] - idx = self.disassemble_bits() - new_idx = list() - for c in lside: - new_idx.append((None, c)) - new_idx.extend(idx) - for c in rside: - new_idx.append((None, c)) - return self.__class__.assemble_bits(new_idx) - - def ljust(self, width: int, fillchar: Union[str, "MudText"] = " "): - diff = width - len(self) - out = self.copy() - if diff <= 0: - return out - else: - if isinstance(fillchar, str): - fillchar = self.__class__(fillchar) - out.append(fillchar * diff) - return out - - def rjust(self, width: int, fillchar: Union[str, "MudText"] = " "): - diff = width - len(self) - if diff <= 0: - return self.copy() - else: - if isinstance(fillchar, str): - fillchar = self.__class__(fillchar) - out = fillchar * diff - out.append(self) - return out - - def lstrip(self, chars: str = None): - lstripped = self.plain.lstrip(chars) - strip_count = len(self.plain) - len(lstripped) - return self[strip_count:] - - def strip(self, chars: str = " "): - out_map = self.disassemble_bits() - for i, e in enumerate(out_map): - if e[1] != chars: - out_map = out_map[i:] - break - out_map.reverse() - for i, e in enumerate(out_map): - if e[1] != chars: - out_map = out_map[i:] - break - out_map.reverse() - return self.__class__.assemble_bits(out_map) - - def replace(self, old: str, new: Union[str, "Text"], count=None) -> "Text": - if not (indexes := self.find_all(old)): - return self.clone() - if count and count > 0: - indexes = indexes[:count] - old_len = len(old) - new_len = len(new) - other = self.clone() - markup_idx_map = self.disassemble_bits() - other_map = other.disassemble_bits() - - for idx in reversed(indexes): - final_markup = markup_idx_map[idx + old_len][0] - diff = abs(old_len - new_len) - replace_chars = min(new_len, old_len) - # First, replace any characters that overlap. - for i in range(replace_chars): - other_map[idx + i] = (markup_idx_map[idx + i][0], new[i]) - if old_len == new_len: - pass # the nicest case. nothing else needs doing. - elif old_len > new_len: - # slightly complex. pop off remaining characters. - for i in range(diff): - deleted = other_map.pop(idx + new_len) - elif new_len > old_len: - # slightly complex. insert new characters. - for i in range(diff): - other_map.insert( - idx + old_len + i, (final_markup, new[old_len + i]) - ) - - return self.__class__.assemble_bits(other_map) - - def find_all(self, sub: str): - indexes = list() - start = 0 - while True: - start = self.plain.find(sub, start) - if start == -1: - return indexes - indexes.append(start) - start += len(sub) - - def scramble(self): - idx = self.disassemble_bits() - random.shuffle(idx) - return self.__class__.assemble_bits(idx) - - def reverse(self): - idx = self.disassemble_bits() - idx.reverse() - return self.__class__.assemble_bits(idx) - - @classmethod - def assemble_bits(cls, idx: List[Tuple[Optional[Union[str, MudStyle, None]], str]]): - out = cls() - for i, t in enumerate(idx): - s = [Span(0, 1, t[0])] - out.append_text(cls(text=t[1], spans=s)) - return out - - def style_at_index(self, offset: int) -> MudStyle: - if offset < 0: - offset = len(self) + offset - style = MudStyle.null() - for start, end, span_style in self._spans: - if end > offset >= start: - style = style + span_style - return style - - def disassemble_bits(self) -> List[Tuple[Optional[Union[str, MudStyle, None]], str]]: - idx = list() - for i, c in enumerate(self.plain): - idx.append((self.style_at_index(i), c)) - return idx - - def squish(self) -> "MudText": - """ - Removes leading and trailing whitespace, and coerces all internal whitespace sequences - into at most a single space. Returns the results. - """ - out = list() - matches = _RE_SQUISH.finditer(self.plain) - for match in matches: - out.append(self[match.start(): match.end()]) - return self.__class__(" ").join(out) - - def squish_spaces(self) -> "MudText": - """ - Like squish, but retains newlines and tabs. Just squishes spaces. - """ - out = list() - matches = _RE_NOTSPACE.finditer(self.plain) - for match in matches: - out.append(self[match.start(): match.end()]) - return self.__class__(" ").join(out) - - def serialize(self) -> dict: - def ser_style(style): - if isinstance(style, str): - style = MudStyle.parse(style) - if not isinstance(style, MudStyle): - style = MudStyle.upgrade(style) - return style.serialize() - - def ser_span(span): - if not span.style: - return None - return { - "start": span.start, - "end": span.end, - "style": ser_style(span.style), - } - - out = {"text": self.plain} - - if self.style: - out["style"] = ser_style(self.style) - - out_spans = [s for span in self.spans if (s := ser_span(span))] - - if out_spans: - out["spans"] = out_spans - - return out - - @classmethod - def deserialize(cls, data) -> "Text": - text = data.get("text", None) - if text is None: - return cls("") - style = data.get("style", None) - if style: - style = MudStyle(**style) - - spans = data.get("spans", None) - - if spans: - spans = [Span(s["start"], s["end"], MudStyle(**s["style"])) for s in spans] - - return cls(text=text, style=style, spans=spans) - - -DEFAULT_STYLES = dict() - - -def install(): - from rich import style, text, console, default_styles, themes, syntax, traceback - global DEFAULT_STYLES - style.Style = MudStyle - style.NULL_STYLE = MudStyle() - text.Text = MudText - console.Console = MudConsole - console.ConsoleOptions = MudConsoleOptions - - traceback.Style = MudStyle - syntax.Style = MudStyle - traceback.Text = MudText - syntax.Text = MudText - - for k, v in default_styles.DEFAULT_STYLES.items(): - DEFAULT_STYLES[k] = MudStyle.upgrade(v) - - for theme in syntax.RICH_SYNTAX_THEMES.values(): - for k, v in theme.items(): - if isinstance(v, OLD_STYLE): - theme[k] = MudStyle.upgrade(v) - - default_styles.DEFAULT_STYLES = DEFAULT_STYLES - themes.DEFAULT = themes.Theme(DEFAULT_STYLES) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index ab4353a10..2120d3ced 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,8 +83,7 @@ dependencies = [ "anything ==0.2.1", "black >= 22.6", "isort >= 5.10", - "parameterized ==0.8.1", - "rich >= 13.3.5, + "parameterized ==0.8.1" ] [project.optional-dependencies] From 3c4a3f10888070a88c95b1c580dabf8212ced007 Mon Sep 17 00:00:00 2001 From: Andrew Bastien Date: Sun, 10 Sep 2023 10:48:03 -0400 Subject: [PATCH 5/6] Removing more erroneous code that snuck in. --- .../Part3/Beginner-Tutorial-Chargen.md | 2 +- .../source/Howtos/Turn-based-Combat-System.md | 8 +-- .../source/Howtos/Web-Character-Generation.md | 4 +- evennia/commands/default/unloggedin.py | 61 ------------------- evennia/server/serversession.py | 5 -- evennia/utils/utils.py | 1 + 6 files changed, 8 insertions(+), 73 deletions(-) diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Chargen.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Chargen.md index 9523bebaa..a7fc661e3 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Chargen.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Chargen.md @@ -622,7 +622,7 @@ node_apply_character(caller, raw_string, **kwargs): tmp_character = kwargs["tmp_character"] new_character = tmp_character.apply(caller) - caller.account.add_character_to_playable_list(new_character) + caller.account.add_character(new_character) text = "Character created!" diff --git a/docs/source/Howtos/Turn-based-Combat-System.md b/docs/source/Howtos/Turn-based-Combat-System.md index e823dee2d..b9168a8e9 100644 --- a/docs/source/Howtos/Turn-based-Combat-System.md +++ b/docs/source/Howtos/Turn-based-Combat-System.md @@ -389,7 +389,7 @@ def resolve_combat(combat_handler, actiondict): for (char, fleevalue) in flee.items(): if fleevalue == 2: combat_handler.msg_all(f"{char} withdraws from combat.") - combat_handler.remove_character_from_playable_list(char) + combat_handler.remove_character(char) ``` To make it simple (and to save space), this example rule module actually resolves each interchange twice - first when it gets to each character and then again when handling the target. Also, since we use the combat handler's `msg_all` method here, the system will get pretty spammy. To clean it up, one could imagine tracking all the possible interactions to make sure each pair is only handled and reported once. @@ -428,13 +428,13 @@ class CmdAttack(Command): # set up combat if target.ndb.combat_handler: # target is already in combat - join it - target.ndb.combat_handler.add_character_to_playable_list(self.caller) + target.ndb.combat_handler.add_character(self.caller) target.ndb.combat_handler.msg_all(f"{self.caller} joins combat!") else: # create a new combat handler chandler = create_script("combat_handler.CombatHandler") - chandler.add_character_to_playable_list(self.caller) - chandler.add_character_to_playable_list(target) + chandler.add_character(self.caller) + chandler.add_character(target) self.caller.msg(f"You attack {target}! You are in combat.") target.msg(f"{self.caller} attacks you! You are in combat.") ``` diff --git a/docs/source/Howtos/Web-Character-Generation.md b/docs/source/Howtos/Web-Character-Generation.md index 17680aab5..e7ef7c823 100644 --- a/docs/source/Howtos/Web-Character-Generation.md +++ b/docs/source/Howtos/Web-Character-Generation.md @@ -206,7 +206,7 @@ def creating(request): # create the character char = create.create_object(typeclass=typeclass, key=name, home=home, permissions=perms) - user.add_character_to_playable_list(char) + user.add_character(char) # add the right locks for the character so the account can # puppet it char.locks.add(" or ".join([ @@ -290,7 +290,7 @@ def creating(request): # create the character char = create.create_object(typeclass=typeclass, key=name, home=home, permissions=perms) - user.add_character_to_playable_list(char) + user.add_character(char) # add the right locks for the character so the account can # puppet it char.locks.add(" or ".join([ diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index c4b7c726a..7bc58dc11 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -465,64 +465,3 @@ class CmdUnconnectedInfo(COMMAND_DEFAULT_CLASS): utils.get_evennia_version(), ) ) - - -def _create_account(session, accountname, password, permissions, typeclass=None, email=None): - """ - Helper function, creates an account of the specified typeclass. - """ - try: - new_account = create.create_account( - accountname, email, password, permissions=permissions, typeclass=typeclass - ) - - except Exception as e: - session.msg( - "There was an error creating the Account:\n%s\n If this problem persists, contact an" - " admin." % e - ) - logger.log_trace() - return False - - # This needs to be set so the engine knows this account is - # logging in for the first time. (so it knows to call the right - # hooks during login later) - new_account.db.FIRST_LOGIN = True - - # join the new account to the public channel - pchannel = ChannelDB.objects.get_channel(settings.DEFAULT_CHANNELS[0]["key"]) - if not pchannel or not pchannel.connect(new_account): - string = "New account '%s' could not connect to public channel!" % new_account.key - logger.log_err(string) - return new_account - - -def _create_character(session, new_account, typeclass, home, permissions): - """ - Helper function, creates a character based on an account's name. - This is meant for Guest and AUTO_CREATRE_CHARACTER_WITH_ACCOUNT=True situations. - """ - try: - new_character = create.create_object( - typeclass, key=new_account.key, home=home, permissions=permissions - ) - # set playable character list - new_account.add_character_to_playable_list(new_character) - - # allow only the character itself and the account to puppet this character (and Developers). - new_character.locks.add( - "puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer)" - % (new_character.id, new_account.id) - ) - - # If no description is set, set a default description - if not new_character.db.desc: - new_character.db.desc = "This is a character." - # We need to set this to have ic auto-connect to this character - new_account.db._last_puppet = new_character - except Exception as e: - session.msg( - "There was an error creating the Character:\n%s\n If this problem persists, contact an" - " admin." % e - ) - logger.log_trace() diff --git a/evennia/server/serversession.py b/evennia/server/serversession.py index 6f535c898..1ea0778f9 100644 --- a/evennia/server/serversession.py +++ b/evennia/server/serversession.py @@ -257,9 +257,6 @@ class ServerSession(_BASE_SESSION_CLASS): for the protocol(s). """ - if (t := kwargs.get("text", None)): - if hasattr(t, "__rich_console__"): - kwargs["text"] = self.print(t) self.sessionhandler.data_out(self, **kwargs) def data_in(self, **kwargs): @@ -296,8 +293,6 @@ class ServerSession(_BASE_SESSION_CLASS): kwargs.pop("session", None) kwargs.pop("from_obj", None) if text is not None: - if hasattr(text, "__rich_console__"): - text = self.print(text) self.data_out(text=text, **kwargs) else: self.data_out(**kwargs) diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 4a2d6ead4..f5338ba24 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -2941,3 +2941,4 @@ def str2int(number): # invalid number-word, raise ValueError raise ValueError(f"String {original_input} cannot be converted to int.") return sum(sums) + From 2bf96f7c7f778256928c05b9070f9f0c5021492f Mon Sep 17 00:00:00 2001 From: Andrew Bastien Date: Sun, 10 Sep 2023 12:23:56 -0400 Subject: [PATCH 6/6] Added CharactersHandler to account and altered all calls of add/remove characters to use it. --- evennia/accounts/accounts.py | 120 +++++++++++++----- evennia/accounts/tests.py | 23 ++-- evennia/commands/default/account.py | 4 +- evennia/commands/default/tests.py | 8 +- .../character_creator/character_creator.py | 2 +- .../contrib/rpg/character_creator/tests.py | 2 +- .../contrib/tutorials/evadventure/chargen.py | 2 +- evennia/objects/objects.py | 4 +- evennia/web/admin/objects.py | 2 +- evennia/web/website/tests.py | 6 +- evennia/web/website/views/help.py | 2 +- 11 files changed, 112 insertions(+), 63 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index bc14b5bf0..3f11882b0 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -120,6 +120,80 @@ class AccountSessionHandler(object): return len(self.get()) +class CharactersHandler: + """ + A simple Handler that lives on DefaultAccount as .characters via @lazy_property used to + wrap access to .db._playable_characters. + """ + + def __init__(self, owner: "DefaultAccount"): + """ + Create the CharactersHandler. + + Args: + owner: The Account that owns this handler. + """ + self.owner = owner + self._ensure_playable_characters() + self._clean() + + def _ensure_playable_characters(self): + if self.owner.db._playable_characters is None: + self.owner.db._playable_characters = [] + + def _clean(self): + # Remove all instances of None from the list. + self.owner.db._playable_characters = [x for x in self.owner.db._playable_characters if x] + + def add(self, character: "DefaultCharacter"): + """ + Add a character to this account's list of playable characters. + + Args: + character (DefaultCharacter): The character to add. + """ + self._clean() + if character not in self.owner.db._playable_characters: + self.owner.db._playable_characters.append(character) + self.owner.at_post_add_character(character) + + def remove(self, character: "DefaultCharacter"): + """ + Remove a character from this account's list of playable characters. + + Args: + character (DefaultCharacter): The character to remove. + """ + self._clean() + if character in self.owner.db._playable_characters: + self.owner.db._playable_characters.remove(character) + self.owner.at_post_remove_character(character) + + def all(self) -> list["DefaultCharacter"]: + """ + Get all playable characters. + + Returns: + list[DefaultCharacter]: All playable characters. + """ + self._clean() + return list(self.owner.db._playable_characters) + + def count(self) -> int: + """ + Get the number of playable characters. + + Returns: + int: The number of playable characters. + """ + return len(self.all()) + + __len__ = count + + def __iter__(self): + return iter(self.all()) + + class DefaultAccount(AccountDB, metaclass=TypeclassBase): """ This is the base Typeclass for all Accounts. Accounts represent @@ -219,56 +293,32 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): load_kwargs={"category": "option"}, ) - # Do not make this a lazy property; the web UI will not refresh it! - @property + @lazy_property def characters(self): - # Get playable characters list - objs = self.db._playable_characters or [] + return CharactersHandler(self) - # Rebuild the list if legacy code left null values after deletion - try: - if None in objs: - objs = [x for x in self.db._playable_characters if x] - self.db._playable_characters = objs - except Exception as e: - logger.log_trace(e) - logger.log_err(e) - - return objs - - def add_character_to_playable_list(self, character: "DefaultCharacter"): - """ - Add a character to this account's list of playable characters. - """ - if character not in self.db._playable_characters: - self.db._playable_characters.append(character) - self.at_post_add_character_to_playable_list(character) - - def at_post_add_character_to_playable_list(self, character: "DefaultCharacter"): + def at_post_add_character(self, character: "DefaultCharacter"): """ Called after a character is added to this account's list of playable characters. Use it to easily implement custom logic when a character is added to an account. + + Args: + character (DefaultCharacter): The character that was added. """ pass - def remove_character_from_playable_list(self, character): - """ - Remove a character from this account's list of playable characters. - """ - if character in self.db._playable_characters: - self.db._playable_characters.remove(character) - self.at_post_remove_character_from_playable_list(character) - - def at_post_remove_character_from_playable_list(self, character): + def at_post_remove_character(self, character: "DefaultAccount"): """ Called after a character is removed from this account's list of playable characters. Use it to easily implement custom logic when a character is removed from an account. + + Args: + character (DefaultCharacter): The character that was removed. """ pass - def uses_screenreader(self, session=None): """ Shortcut to determine if a session uses a screenreader. If no session given, @@ -776,7 +826,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): ) if character: # Update playable character list - self.add_character_to_playable_list(character) + self.characters.add(character) # We need to set this to have @ic auto-connect to this character self.db._last_puppet = character diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index c35609589..18b4025d9 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -105,14 +105,14 @@ class TestDefaultGuest(BaseEvenniaTest): def test_at_server_shutdown(self): account, errors = DefaultGuest.create(ip=self.ip) self.char1.delete = MagicMock() - account.add_character_to_playable_list(self.char1) + account.characters.add(self.char1) account.at_server_shutdown() self.char1.delete.assert_called() def test_at_post_disconnect(self): account, errors = DefaultGuest.create(ip=self.ip) self.char1.delete = MagicMock() - account.add_character_to_playable_list(self.char1) + account.characters.add(self.char1) account.at_post_disconnect() self.char1.delete.assert_called() @@ -362,7 +362,7 @@ class TestAccountPuppetDeletion(BaseEvenniaTest): ) # Add char1 to account's playable characters - self.account.add_character_to_playable_list(self.char1) + self.account.characters.add(self.char1) self.assertTrue(self.account.characters, "Char was not added to account.") # See what happens when we delete char1. @@ -383,20 +383,19 @@ class TestDefaultAccountEv(BaseEvenniaTest): def test_characters_property(self): "test existence of None in _playable_characters Attr" self.account.db._playable_characters = [self.char1, None] - chars = self.account.characters - self.assertEqual(chars, [self.char1]) + self.assertEqual(self.account.characters.all(), [self.char1]) self.assertEqual(self.account.db._playable_characters, [self.char1]) def test_add_character_to_playable_list(self): - self.assertEqual(self.account.characters, []) - self.account.add_character_to_playable_list(self.char1) - self.assertEqual(self.account.characters, [self.char1]) + self.assertEqual(self.account.characters.all(), []) + self.account.characters.add(self.char1) + self.assertEqual(self.account.characters.all(), [self.char1]) def test_remove_character_from_playable_list(self): - self.account.add_character_to_playable_list(self.char1) - self.assertEqual(self.account.characters, [self.char1]) - self.account.remove_character_from_playable_list(self.char1) - self.assertEqual(self.account.characters, []) + self.account.characters.add(self.char1) + self.assertEqual(self.account.characters.all(), [self.char1]) + self.account.characters.remove(self.char1) + self.assertEqual(self.account.characters.all(), []) def test_puppet_success(self): self.account.msg = MagicMock() diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index 571d9f7ee..5e38dea46 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -179,7 +179,7 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS): "puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer);delete:id(%i) or" " perm(Admin)" % (new_character.id, account.id, account.id) ) - account.add_character_to_playable_list(new_character) + account.characters.add(new_character) if desc: new_character.db.desc = desc elif not new_character.db.desc: @@ -238,7 +238,7 @@ class CmdCharDelete(COMMAND_DEFAULT_CLASS): # only take action delobj = caller.ndb._char_to_delete key = delobj.key - caller.remove_character_from_playable_list(delobj) + caller.characters.remove(delobj) delobj.delete() self.msg(f"Character '{key}' was permanently deleted.") logger.log_sec( diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 0e25737e8..774bd8333 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -589,7 +589,7 @@ class TestAccount(BaseEvenniaCommandTest): ] ) def test_ooc_look(self, multisession_mode, auto_puppet, max_nr_chars, expected_result): - self.account.add_character_to_playable_list(self.char1) + self.account.characters.add(self.char1) self.account.unpuppet_all() with self.settings(MULTISESSION=multisession_mode): @@ -609,14 +609,14 @@ class TestAccount(BaseEvenniaCommandTest): self.call(account.CmdOOC(), "", "You go OOC.", caller=self.account) def test_ic(self): - self.account.add_character_to_playable_list(self.char1) + self.account.characters.add(self.char1) self.account.unpuppet_object(self.session) self.call( account.CmdIC(), "Char", "You become Char.", caller=self.account, receiver=self.char1 ) def test_ic__other_object(self): - self.account.add_character_to_playable_list(self.obj1) + self.account.characters.add(self.obj1) self.account.unpuppet_object(self.session) self.call( account.CmdIC(), "Obj", "You become Obj.", caller=self.account, receiver=self.obj1 @@ -670,7 +670,7 @@ class TestAccount(BaseEvenniaCommandTest): # whether permissions are being checked # Add char to account playable characters - self.account.add_character_to_playable_list(self.char1) + self.account.characters.add(self.char1) # Try deleting as Developer self.call( diff --git a/evennia/contrib/rpg/character_creator/character_creator.py b/evennia/contrib/rpg/character_creator/character_creator.py index b6c0ae51e..8ebc49435 100644 --- a/evennia/contrib/rpg/character_creator/character_creator.py +++ b/evennia/contrib/rpg/character_creator/character_creator.py @@ -90,7 +90,7 @@ class ContribCmdCharCreate(MuxAccountCommand): ) # initalize the new character to the beginning of the chargen menu new_character.db.chargen_step = "menunode_welcome" - account.add_character_to_playable_list(new_character) + account.characters.add(new_character) # set the menu node to start at to the character's last saved step startnode = new_character.db.chargen_step diff --git a/evennia/contrib/rpg/character_creator/tests.py b/evennia/contrib/rpg/character_creator/tests.py index e6aec7fd9..5cdaaa0ec 100644 --- a/evennia/contrib/rpg/character_creator/tests.py +++ b/evennia/contrib/rpg/character_creator/tests.py @@ -17,7 +17,7 @@ class TestCharacterCreator(BaseEvenniaCommandTest): self.account.swap_typeclass(character_creator.ContribChargenAccount) def test_ooc_look(self): - self.account.add_character_to_playable_list(self.char1) + self.account.characters.add(self.char1) self.account.unpuppet_all() self.char1.db.chargen_step = "start" diff --git a/evennia/contrib/tutorials/evadventure/chargen.py b/evennia/contrib/tutorials/evadventure/chargen.py index fed7b1c76..ab440d2f4 100644 --- a/evennia/contrib/tutorials/evadventure/chargen.py +++ b/evennia/contrib/tutorials/evadventure/chargen.py @@ -316,7 +316,7 @@ def node_apply_character(caller, raw_string, **kwargs): """ tmp_character = kwargs["tmp_character"] new_character = tmp_character.apply(caller) - caller.add_character_to_playable_list(new_character) + caller.characters.add(new_character) text = "Character created!" diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 4b6605efa..f8f98b552 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -1149,7 +1149,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): # sever the connection (important!) if self.account: # Remove the object from playable characters list - self.account.remove_character_from_playable_list(self) + self.account.characters.remove(self) for session in self.sessions.all(): self.account.unpuppet_object(session) @@ -2559,7 +2559,7 @@ class DefaultCharacter(DefaultObject): obj.db.creator_ip = ip if account: obj.db.creator_id = account.id - account.add_character_to_playable_list(obj) + account.characters.add(obj) # Add locks if not locks and account: diff --git a/evennia/web/admin/objects.py b/evennia/web/admin/objects.py index da73a4931..48e5fe99e 100644 --- a/evennia/web/admin/objects.py +++ b/evennia/web/admin/objects.py @@ -319,7 +319,7 @@ class ObjectAdmin(admin.ModelAdmin): if account: account.db._last_puppet = obj - account.add_character_to_playable_list(obj) + account.characters.add(obj) if not obj.access(account, "puppet"): lock = obj.locks.get("puppet") lock += f" or pid({account.id})" diff --git a/evennia/web/website/tests.py b/evennia/web/website/tests.py index e5b857109..230d4442f 100644 --- a/evennia/web/website/tests.py +++ b/evennia/web/website/tests.py @@ -35,8 +35,8 @@ class EvenniaWebTest(BaseEvenniaTest): super().setUp() # Add chars to account rosters - self.account.add_character_to_playable_list(self.char1) - self.account2.add_character_to_playable_list(self.char2) + self.account.characters.add(self.char1) + self.account2.characters.add(self.char2) for account in (self.account, self.account2): # Demote accounts to Player permissions @@ -220,7 +220,7 @@ class CharacterCreateView(EvenniaWebTest): @override_settings(MAX_NR_CHARACTERS=1) def test_valid_access_multisession_0(self): "Account1 with no characters should be able to create a new one" - self.account.remove_character_from_playable_list(self.char1) + self.account.characters.remove(self.char1) # Login account self.login() diff --git a/evennia/web/website/views/help.py b/evennia/web/website/views/help.py index c416f4e73..98ef5cbcb 100644 --- a/evennia/web/website/views/help.py +++ b/evennia/web/website/views/help.py @@ -102,7 +102,7 @@ def collect_topics(account): cmd_help_topics = [] if not str(account) == "AnonymousUser": # create list of account and account's puppets - puppets = account.characters + [account] + puppets = account.characters.all() + [account] # add the account's and puppets' commands to cmd_help_topics list for puppet in puppets: for cmdset in puppet.cmdset.get():