diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 17f17ad9b8..4eb63d93f6 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -12,16 +12,16 @@ instead for most things). """ import re import time +import typing from random import getrandbits +import evennia from django.conf import settings from django.contrib.auth import authenticate, password_validation from django.core.exceptions import ImproperlyConfigured, ValidationError from django.utils import timezone from django.utils.module_loading import import_string from django.utils.translation import gettext as _ - -import evennia from evennia.accounts.manager import AccountManager from evennia.accounts.models import AccountDB from evennia.commands.cmdsethandler import CmdSetHandler @@ -272,6 +272,9 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): objects = AccountManager() + # Used by account.create_character() to choose default typeclass for characters. + default_character_typeclass = settings.BASE_CHARACTER_TYPECLASS + # properties @lazy_property def cmdset(self): @@ -315,7 +318,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): """ pass - def at_post_remove_character(self, character: "DefaultAccount"): + def at_post_remove_character(self, character: "DefaultCharacter"): """ Called after a character is removed from this account's list of playable characters. @@ -790,6 +793,48 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): logger.log_sec(f"Password successfully changed for {self}.") self.at_password_change() + def get_character_slots(self) -> typing.Optional[int]: + """ + Returns the number of character slots this account has, or + None if there are no limits. + + By default, that's settings.MAX_NR_CHARACTERS but this makes it easy to override. + Maybe for your game, players can be rewarded with more slots, somehow. + + Returns: + int (optional): The number of character slots this account has, or None + if there are no limits. + """ + return settings.MAX_NR_CHARACTERS + + def get_available_character_slots(self) -> typing.Optional[int]: + """ + Returns the number of character slots this account has available, or None if + there are no limits. + + Returns: + int (optional): The number of open character slots this account has, or None + if there are no limits. + """ + if (slots := self.get_character_slots()) is None: + return None + return max(0, slots - len(self.characters)) + + def check_available_slots(self, **kwargs) -> typing.Optional[str]: + """ + Helper method used to determine if an account can create additional characters using + the character slot system. + + Returns: + str (optional): An error message regarding the status of slots. If present, this + will halt character creation. If not, character creation can proceed. + """ + if (slots := self.get_available_character_slots()) is not None: + if slots <= 0: + if not (self.is_superuser or self.check_permstring("Developer")): + plural = "" if (max_slots := self.get_character_slots()) == 1 else "s" + return f"You may only have a maximum of {max_slots} character{plural}." + def create_character(self, *args, **kwargs): """ Create a character linked to this account. @@ -797,7 +842,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): Args: key (str, optional): If not given, use the same name as the account. typeclass (str, optional): Typeclass to use for this character. If - not given, use settings.BASE_CHARACTER_TYPECLASS. + not given, use self.default_character_class. permissions (list, optional): If not given, use the account's permissions. ip (str, optional): The client IP creating this character. Will fall back to the one stored for the account if not given. @@ -807,16 +852,17 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): list or None: A list of errors, or None. """ + # check character slot usage. + if (slot_check := self.check_available_slots()): + return None, [slot_check] + # parse inputs character_key = kwargs.pop("key", self.key) character_ip = kwargs.pop("ip", self.db.creator_ip) character_permissions = kwargs.pop("permissions", self.permissions) # Load the appropriate Character class - character_typeclass = kwargs.pop("typeclass", None) - character_typeclass = ( - character_typeclass if character_typeclass else settings.BASE_CHARACTER_TYPECLASS - ) + character_typeclass = kwargs.pop("typeclass", self.default_character_typeclass) Character = class_from_module(character_typeclass) if "location" not in kwargs: @@ -832,12 +878,29 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): **kwargs, ) if character: - # Update playable character list + self.at_post_create_character(character, ip=character_ip) + + return character, errs + + def at_post_create_character(self, character, **kwargs): + """ + An overloadable hook method that allows for further customization of newly created characters. + """ + if character not in self.characters: self.characters.add(character) - # We need to set this to have @ic auto-connect to this character + # We need to set this to have @ic auto-connect to this character + if len(self.characters) == 1: self.db._last_puppet = character - return character, errs + + character.locks.add( + f"puppet:id({character.id}) or pid({self.id}) or perm(Developer) or pperm(Developer);delete:id({self.id}) or" + " perm(Admin)" + ) + + logger.log_sec( + f"Character Created: {character} (Caller: {self}, IP: {kwargs.get('ip', None)})." + ) @classmethod def create(cls, *args, **kwargs): @@ -962,7 +1025,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): # Auto-create a character to go with this account character, errs = account.create_character( - typeclass=kwargs.get("character_typeclass") + typeclass=kwargs.get("character_typeclass", account.default_character_typeclass) ) if errs: errors.extend(errs) diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index c85131d48d..f49438f69b 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -352,6 +352,32 @@ class TestDefaultAccount(TestCase): ) self.assertIsNone(obj.at_post_puppet.call_args) + @override_settings(MAX_NR_CHARACTERS=5) + def test_get_character_slots(self): + "Check get_character_slots method" + + account = create.create_account( + f"TestAccount{randint(0, 999999)}", + email="test@test.com", + password="testpassword", + typeclass=DefaultAccount, + ) + + self.assertEqual(account.get_character_slots(), 5) + account.delete() + + @override_settings(MAX_NR_CHARACTERS=5) + def test_get_available_character_slots(self): + "Check get_available_character_slots method" + account = create.create_account( + f"TestAccount{randint(0, 999999)}", + email="test@test.com", + password="testpassword", + typeclass=DefaultAccount, + ) + self.assertEqual(account.get_available_character_slots(), 5) + account.delete() + class TestAccountPuppetDeletion(BaseEvenniaTest): @override_settings(MULTISESSION_MODE=2) diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index acbd071ddf..67231a657a 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -146,53 +146,20 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS): self.msg("Usage: charcreate [= description]") return key = self.lhs - desc = self.rhs + description = self.rhs or "This is a character." - if _MAX_NR_CHARACTERS is not None: - if ( - not account.is_superuser - and not account.check_permstring("Developer") - 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}.") - return - from evennia.objects.models import ObjectDB + new_character, errors = self.account.create_character(key=key, description=description, ip=self.session.address) - typeclass = settings.BASE_CHARACTER_TYPECLASS - - if ObjectDB.objects.filter(db_typeclass_path=typeclass, db_key__iexact=key): - # check if this Character already exists. Note that we are only - # searching the base character typeclass here, not any child - # classes. - self.msg(f"|rA character named '|w{key}|r' already exists.|n") + if errors: + self.msg(errors) + if not new_character: return - # create the character - start_location = ObjectDB.objects.get_id(settings.START_LOCATION) - default_home = ObjectDB.objects.get_id(settings.DEFAULT_HOME) - permissions = settings.PERMISSION_ACCOUNT_DEFAULT - new_character = create.create_object( - typeclass, key=key, location=start_location, home=default_home, permissions=permissions - ) - # only allow creator (and developers) to puppet this char - new_character.locks.add( - "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.characters.add(new_character) - if desc: - new_character.db.desc = desc - elif not new_character.db.desc: - new_character.db.desc = "This is a character." self.msg( f"Created new character {new_character.key}. Use |wic {new_character.key}|n to enter" " the game as this character." ) - logger.log_sec( - f"Character Created: {new_character} (Caller: {account}, IP: {self.session.address})." - ) + class CmdCharDelete(COMMAND_DEFAULT_CLASS): diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index c710e96498..b117cb613e 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -9,6 +9,7 @@ This is the v1.0 develop version (for ref in doc building). """ import time from collections import defaultdict +import typing import inflect from django.conf import settings @@ -2529,8 +2530,8 @@ class DefaultCharacter(DefaultObject): # Normalize to latin characters and validate, if necessary, the supplied key key = cls.normalize_name(key) - if not cls.validate_name(key): - errors.append(_("Invalid character name.")) + if (val_err := cls.validate_name(key, account=account)): + errors.append(val_err) return obj, errors # Set the supplied key as the name of the intended object @@ -2548,8 +2549,9 @@ class DefaultCharacter(DefaultObject): try: # Check to make sure account does not have too many chars if account: - if len(account.characters) >= settings.MAX_NR_CHARACTERS: - errors.append(_("There are too many characters associated with this account.")) + avail = account.check_available_slots() + if avail: + errors.append(avail) return obj, errors # Create the Character @@ -2603,17 +2605,20 @@ class DefaultCharacter(DefaultObject): return latin_name @classmethod - def validate_name(cls, name): - """Validate the character name prior to creating. Overload this function to add custom validators + def validate_name(cls, name, account=None) -> typing.Optional[str]: + """ + Validate the character name prior to creating. Overload this function to add custom validators Args: name (str) : The name of the character + Kwargs: + account (DefaultAccount, optional) : The account creating the character. Returns: - valid (bool) : True if character creation should continue; False if it should fail + error (str, optional) : A non-empty error message if there is a problem, otherwise False. """ - - return True # Default validator does not perform any operations + if account and cls.objects.filter_family(db_key__iexact=name): + return f"|rA character named '|w{name}|r' already exists.|n" def basetype_setup(self): """