Merge branch 'master' into latin_il8n

Added Latin translation of Evennia's core messages as per the directions
in evennia/locale/README.
This commit is contained in:
jamalainm 2021-02-05 15:52:14 -06:00
commit ce469f2765
590 changed files with 51168 additions and 4796 deletions

View file

@ -1 +1 @@
0.9.0
0.9.5

View file

@ -5,7 +5,7 @@ This is the main top-level API for Evennia. You can explore the evennia library
by accessing evennia.<subpackage> directly. From inside the game you can read
docs of all object by viewing its `__doc__` string, such as through
@py evennia.ObjectDB.__doc__
py evennia.ObjectDB.__doc__
For full functionality you should explore this module via a django-
aware shell. Go to your game directory and use the command
@ -20,27 +20,13 @@ See www.evennia.com for full documentation.
# docstring header
DOCSTRING = """
|cEvennia|n 'flat' API (use |wevennia.<component>.__doc__|n to read doc-strings
and |wdict(evennia.component)|n or
|wevennia.component.__dict__ to see contents)
|cTypeclass-bases:|n |cDatabase models|n:
DefaultAccount DefaultObject AccountDB ObjectDB
DefaultGuest DefaultCharacter ChannelDB
DefaultRoom ScriptDB
DefaultChannel DefaultExit Msg
DefaultScript
|cSearch functions:|n |cCommand parents and helpers:|n
search_account search_object default_cmds
search_script search_channel Command InterruptCommand
search_help search_message CmdSet
search_tag managers |cUtilities:|n
|cCreate functions:|n settings lockfuncs
create_account create_object logger gametime
create_script create_channel ansi spawn
create_help_entry create_message contrib managers
|cGlobal handlers:|n set_trace
TICKER_HANDLER TASK_HANDLER EvMenu EvTable
SESSION_HANDLER CHANNEL_HANDLER EvForm EvEditor """
Evennia MU* creation system.
Online manual and API docs are found at http://www.evennia.com.
Flat-API shortcut names:
{}
"""
# Delayed loading of properties
@ -231,6 +217,7 @@ def _init():
from . import contrib
from .utils.evmenu import EvMenu
from .utils.evtable import EvTable
from .utils.evmore import EvMore
from .utils.evform import EvForm
from .utils.eveditor import EvEditor
from .utils.ansi import ANSIString
@ -247,10 +234,6 @@ def _init():
from .utils.containers import GLOBAL_SCRIPTS
from .utils.containers import OPTION_CLASSES
# initialize the doc string
global __doc__
__doc__ = ansi.parse_ansi(DOCSTRING)
# API containers
class _EvContainer(object):
@ -409,11 +392,15 @@ def _init():
BASE_GUEST_TYPECLASS = class_from_module(settings.BASE_GUEST_TYPECLASS)
del class_from_module
# delayed starts
# delayed starts - important so as to not back-access evennia before it has
# finished initializing
GLOBAL_SCRIPTS.start()
from .prototypes import prototypes
prototypes.load_module_prototypes()
del prototypes
def set_trace(term_size=(140, 40), debugger="auto"):
def set_trace(term_size=(140, 80), debugger="auto"):
"""
Helper function for running a debugger inside the Evennia event loop.
@ -460,9 +447,21 @@ def set_trace(term_size=(140, 40), debugger="auto"):
dbg = pdb.Pdb(stdout=sys.__stdout__)
try:
# Start debugger, forcing it up one stack frame (otherwise `set_trace` will start debugger
# this point, not the actual code location)
# Start debugger, forcing it up one stack frame (otherwise `set_trace`
# will start debugger this point, not the actual code location)
dbg.set_trace(sys._getframe().f_back)
except Exception:
# Stopped at breakpoint. Press 'n' to continue into the code.
dbg.set_trace()
# initialize the doc string
global __doc__
__doc__ = DOCSTRING.format(
"\n- "
+ "\n- ".join(
f"evennia.{key}"
for key in sorted(globals())
if not key.startswith("_") and key not in ("DOCSTRING",)
)
)

View file

@ -4,7 +4,7 @@ Typeclass for Account objects
Note that this object is primarily intended to
store OOC information, not game info! This
object represents the actual user (not their
character) and has NO actual precence in the
character) and has NO actual presence in the
game world (this is handled by the associated
character object, so you should customize that
instead for most things).
@ -37,7 +37,7 @@ from evennia.scripts.scripthandler import ScriptHandler
from evennia.commands.cmdsethandler import CmdSetHandler
from evennia.utils.optionhandler import OptionHandler
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from random import getrandbits
__all__ = ("DefaultAccount",)
@ -51,8 +51,12 @@ _CMDSET_ACCOUNT = settings.CMDSET_ACCOUNT
_MUDINFO_CHANNEL = None
# Create throttles for too many account-creations and login attempts
CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60)
LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60)
CREATION_THROTTLE = Throttle(
limit=settings.CREATION_THROTTLE_LIMIT, timeout=settings.CREATION_THROTTLE_TIMEOUT
)
LOGIN_THROTTLE = Throttle(
limit=settings.LOGIN_THROTTLE_LIMIT, timeout=settings.LOGIN_THROTTLE_TIMEOUT
)
class AccountSessionHandler(object):
@ -180,7 +184,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
- at_server_reload()
- at_server_shutdown()
"""
"""
objects = AccountManager()
@ -216,12 +220,16 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
@property
def characters(self):
# Get playable characters list
objs = self.db._playable_characters
objs = self.db._playable_characters or []
# Rebuild the list if legacy code left null values after deletion
if None in objs:
objs = [x for x in self.db._playable_characters if x]
self.db._playable_characters = objs
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
@ -267,11 +275,11 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
raise RuntimeError("Session not found")
if self.get_puppet(session) == obj:
# already puppeting this object
self.msg("You are already puppeting this object.")
self.msg(_("You are already puppeting this object."))
return
if not obj.access(self, "puppet"):
# no access
self.msg(f"You don't have permission to puppet '{obj.key}'.")
self.msg(_("You don't have permission to puppet '{key}'.").format(key=obj.key))
return
if obj.account:
# object already puppeted
@ -287,12 +295,12 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
else:
txt1 = f"Taking over |c{obj.name}|n from another of your sessions."
txt2 = f"|c{obj.name}|n|R is now acted from another of your sessions.|n"
self.msg(txt1, session=session)
self.msg(txt2, session=obj.sessions.all())
self.msg(_(txt1), session=session)
self.msg(_(txt2), session=obj.sessions.all())
self.unpuppet_object(obj.sessions.get())
elif obj.account.is_connected:
# controlled by another account
self.msg(f"|c{obj.key}|R is already puppeted by another Account.")
self.msg(_("|c{key}|R is already puppeted by another Account.").format(key=obj.key))
return
# do the puppeting
@ -365,7 +373,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
puppet (Object): The matching puppeted object, if any.
"""
return session.puppet
return session.puppet if session else None
def get_all_puppets(self):
"""
@ -402,7 +410,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
"""
Checks if a given username or IP is banned.
Kwargs:
Keyword Args:
ip (str, optional): IP address.
username (str, optional): Username.
@ -473,7 +481,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
password (str): Password of account
ip (str, optional): IP address of client
Kwargs:
Keyword Args:
session (Session, optional): Session requesting authentication
Returns:
@ -488,7 +496,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
# See if authentication is currently being throttled
if ip and LOGIN_THROTTLE.check(ip):
errors.append("Too many login failures; please try again in a few minutes.")
errors.append(_("Too many login failures; please try again in a few minutes."))
# With throttle active, do not log continued hits-- it is a
# waste of storage and can be abused to make your logs harder to
@ -500,8 +508,10 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
if banned:
# this is a banned IP or name!
errors.append(
"|rYou have been banned and cannot continue from here."
"\nIf you feel this ban is in error, please email an admin.|x"
_(
"|rYou have been banned and cannot continue from here."
"\nIf you feel this ban is in error, please email an admin.|x"
)
)
logger.log_sec(f"Authentication Denied (Banned): {username} (IP: {ip}).")
LOGIN_THROTTLE.update(ip, "Too many sightings of banned artifact.")
@ -511,7 +521,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
account = authenticate(username=username, password=password)
if not account:
# User-facing message
errors.append("Username and/or password is incorrect.")
errors.append(_("Username and/or password is incorrect."))
# Log auth failures while throttle is inactive
logger.log_sec(f"Authentication Failure: {username} (IP: {ip}).")
@ -601,7 +611,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
Args:
password (str): Password to validate
Kwargs:
Keyword Args:
account (DefaultAccount, optional): Account object to validate the
password for. Optional, but Django includes some validators to
do things like making sure users aren't setting passwords to the
@ -641,6 +651,56 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
logger.log_sec(f"Password successfully changed for {self}.")
self.at_password_change()
def create_character(self, *args, **kwargs):
"""
Create a character linked to this account.
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.
permissions (list, optional): If not given, use the account's permissions.
ip (str, optiona): The client IP creating this character. Will fall back to the
one stored for the account if not given.
kwargs (any): Other kwargs will be used in the create_call.
Returns:
Object: A new character of the `character_typeclass` type. None on an error.
list or None: A list of errors, or None.
"""
# 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 = class_from_module(character_typeclass)
if "location" not in kwargs:
kwargs["location"] = ObjectDB.objects.get_id(settings.START_LOCATION)
# Create the character
character, errs = Character.create(
character_key,
self,
ip=character_ip,
typeclass=character_typeclass,
permissions=character_permissions,
**kwargs,
)
if character:
# Update playable character list
if character not in self.characters:
self.db._playable_characters.append(character)
# We need to set this to have @ic auto-connect to this character
self.db._last_puppet = character
return character, errs
@classmethod
def create(cls, *args, **kwargs):
"""
@ -648,7 +708,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
with default (or overridden) permissions and having joined them to the
appropriate default channels.
Kwargs:
Keyword Args:
username (str): Username of Account owner
password (str): Password of Account owner
email (str, optional): Email address of Account owner
@ -680,7 +740,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
ip = kwargs.get("ip", "")
if ip and CREATION_THROTTLE.check(ip):
errors.append(
"You are creating too many accounts. Please log into an existing account."
_("You are creating too many accounts. Please log into an existing account.")
)
return None, errors
@ -708,7 +768,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
banned = cls.is_banned(username=username, ip=ip)
if banned:
# this is a banned IP or name!
string = (
string = _(
"|rYou have been banned and cannot continue from here."
"\nIf you feel this ban is in error, please email an admin.|x"
)
@ -725,7 +785,9 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
except Exception as e:
errors.append(
"There was an error creating the Account. If this problem persists, contact an admin."
_(
"There was an error creating the Account. If this problem persists, contact an admin."
)
)
logger.log_trace()
return None, errors
@ -747,37 +809,19 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
logger.log_err(string)
if account and settings.MULTISESSION_MODE < 2:
# Load the appropriate Character class
character_typeclass = kwargs.get(
"character_typeclass", settings.BASE_CHARACTER_TYPECLASS
# Auto-create a character to go with this account
character, errs = account.create_character(
typeclass=kwargs.get("character_typeclass")
)
character_home = kwargs.get("home")
Character = class_from_module(character_typeclass)
# Create the character
character, errs = Character.create(
account.key,
account,
ip=ip,
typeclass=character_typeclass,
permissions=permissions,
home=character_home,
)
errors.extend(errs)
if character:
# Update playable character list
if character not in account.characters:
account.db._playable_characters.append(character)
# We need to set this to have @ic auto-connect to this character
account.db._last_puppet = character
if errs:
errors.extend(errs)
except Exception:
# We are in the middle between logged in and -not, so we have
# to handle tracebacks ourselves at this point. If we don't,
# we won't see any errors at all.
errors.append("An error occurred. Please e-mail an admin if the problem persists.")
errors.append(_("An error occurred. Please e-mail an admin if the problem persists."))
logger.log_trace()
# Update the throttle to indicate a new account was created from this IP
@ -820,7 +864,10 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
server.
Args:
text (str, optional): text data to send
text (str or tuple, optional): The message to send. This
is treated internally like any send-command, so its
value can be a tuple if sending multiple arguments to
the `text` oob command.
from_obj (Object or Account or list, optional): Object sending. If given, its
at_msg_send() hook will be called. If iterable, call on all entities.
session (Session or list, optional): Session object or a list of
@ -828,7 +875,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
default send behavior for the current
MULTISESSION_MODE.
options (list): Protocol-specific options. Passed on to the protocol.
Kwargs:
Keyword Args:
any (dict): All other keywords are passed on to the protocol.
"""
@ -851,7 +898,13 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
kwargs["options"] = options
if text is not None:
kwargs["text"] = to_str(text)
if not (isinstance(text, str) or isinstance(text, tuple)):
# sanitize text before sending across the wire
try:
text = to_str(text)
except Exception:
text = repr(text)
kwargs["text"] = text
# session relay
sessions = make_iter(session) if session else self.sessions.all()
@ -870,7 +923,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
session (Session, optional): The session to be responsible
for the command-send
Kwargs:
Keyword Args:
kwargs (any): Other keyword arguments will be added to the
found command object instance as variables before it
executes. This is unused by default Evennia but may be
@ -899,6 +952,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
nofound_string=None,
multimatch_string=None,
use_nicks=True,
quiet=False,
**kwargs,
):
"""
@ -925,9 +979,13 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
message to echo if `searchdata` leads to multiple matches.
If not given, will fall back to the default handler.
use_nicks (bool, optional): Use account-level nick replacement.
quiet (bool, optional): If set, will not show any error to the user,
and will also lead to returning a list of matches.
Return:
match (Account, Object or None): A single Account or Object match.
list: If `quiet=True` this is a list of 0, 1 or more Account or Object matches.
Notes:
Extra keywords are ignored, but are allowed in call in
order to make API more consistent with
@ -939,28 +997,31 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
# handle wrapping of common terms
if searchdata.lower() in ("me", "*me", "self", "*self"):
return self
if search_object:
matches = ObjectDB.objects.object_search(
searchdata, typeclass=typeclass, use_nicks=use_nicks
)
else:
searchdata = self.nicks.nickreplace(
searchdata, categories=("account",), include_account=False
)
matches = AccountDB.objects.account_search(searchdata, typeclass=typeclass)
matches = _AT_SEARCH_RESULT(
matches,
self,
query=searchdata,
nofound_string=nofound_string,
multimatch_string=multimatch_string,
searchdata = self.nicks.nickreplace(
searchdata, categories=("account",), include_account=False
)
if matches and return_puppet:
try:
return matches.puppet
except AttributeError:
return None
if search_object:
matches = ObjectDB.objects.object_search(searchdata, typeclass=typeclass)
else:
matches = AccountDB.objects.account_search(searchdata, typeclass=typeclass)
if quiet:
matches = list(matches)
if return_puppet:
matches = [match.puppet for match in matches]
else:
matches = _AT_SEARCH_RESULT(
matches,
self,
query=searchdata,
nofound_string=nofound_string,
multimatch_string=multimatch_string,
)
if matches and return_puppet:
try:
matches = matches.puppet
except AttributeError:
return None
return matches
def access(
@ -978,7 +1039,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
no_superuser_bypass (bool, optional): Turn off superuser
lock bypassing. Be careful with this one.
Kwargs:
Keyword Args:
kwargs (any): Passed to the at_access hook along with the result.
Returns:
@ -1122,7 +1183,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
check.
access_type (str): The type of access checked.
Kwargs:
Keyword Args:
kwargs (any): These are passed on from the access check
and can be used to relay custom instructions from the
check mechanism.
@ -1204,7 +1265,10 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
]
except Exception:
logger.log_trace()
now = timezone.now()
if settings.USE_TZ:
now = timezone.localtime()
else:
now = timezone.now()
now = "%02i-%02i-%02i(%02i:%02i)" % (now.year, now.month, now.day, now.hour, now.minute)
if _MUDINFO_CHANNEL:
_MUDINFO_CHANNEL.tempmsg(f"[{_MUDINFO_CHANNEL.key}, {now}]: {message}")
@ -1236,21 +1300,21 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
if session:
session.msg(logged_in={})
self._send_to_connect_channel(f"|G{self.key} connected|n")
self._send_to_connect_channel(_("|G{key} connected|n").format(key=self.key))
if _MULTISESSION_MODE == 0:
# in this mode we should have only one character available. We
# try to auto-connect to our last conneted object, if any
try:
self.puppet_object(session, self.db._last_puppet)
except RuntimeError:
self.msg("The Character does not exist.")
self.msg(_("The Character does not exist."))
return
elif _MULTISESSION_MODE == 1:
# in this mode all sessions connect to the same puppet.
try:
self.puppet_object(session, self.db._last_puppet)
except RuntimeError:
self.msg("The Character does not exist.")
self.msg(_("The Character does not exist."))
return
elif _MULTISESSION_MODE in (2, 3):
# In this mode we by default end up at a character selection
@ -1288,7 +1352,9 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
"""
reason = f" ({reason if reason else ''})"
self._send_to_connect_channel(f"|R{self.key} disconnected{reason}|n")
self._send_to_connect_channel(
_("|R{key} disconnected{reason}|n").format(key=self.key, reason=reason)
)
def at_post_disconnect(self, **kwargs):
"""
@ -1322,7 +1388,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
text (str, optional): The message received.
from_obj (any, optional): The object sending the message.
Kwargs:
Keyword Args:
This includes any keywords sent to the `msg` method.
Returns:
@ -1344,7 +1410,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
text (str, optional): Text to send.
to_obj (any, optional): The object to send to.
Kwargs:
Keyword Args:
Keywords passed from msg()
Notes:
@ -1394,7 +1460,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
if hasattr(target, "return_appearance"):
return target.return_appearance(self)
else:
return "{} has no in-game appearance.".format(target)
return _("{target} has no in-game appearance.").format(target=target)
else:
# list of targets - make list to disconnect from db
characters = list(tar for tar in target if tar) if target else []
@ -1437,7 +1503,9 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
if is_su or len(characters) < charmax:
if not characters:
result.append(
"\n\n You don't have any characters yet. See |whelp @charcreate|n for creating one."
_(
"\n\n You don't have any characters yet. See |whelp @charcreate|n for creating one."
)
)
else:
result.append("\n |w@charcreate <name> [=description]|n - create new character")
@ -1501,7 +1569,7 @@ class DefaultGuest(DefaultAccount):
"""
Gets or creates a Guest account object.
Kwargs:
Keyword Args:
ip (str, optional): IP address of requestor; used for ban checking,
throttling and logging
@ -1517,17 +1585,17 @@ class DefaultGuest(DefaultAccount):
# check if guests are enabled.
if not settings.GUEST_ENABLED:
errors.append("Guest accounts are not enabled on this server.")
errors.append(_("Guest accounts are not enabled on this server."))
return None, errors
try:
# Find an available guest name.
for name in settings.GUEST_LIST:
if not AccountDB.objects.filter(username__iexact=name).count():
if not AccountDB.objects.filter(username__iexact=name).exists():
username = name
break
if not username:
errors.append("All guest accounts are in use. Please try again later.")
errors.append(_("All guest accounts are in use. Please try again later."))
if ip:
LOGIN_THROTTLE.update(ip, "Too many requests for Guest access.")
return None, errors
@ -1549,13 +1617,22 @@ class DefaultGuest(DefaultAccount):
ip=ip,
)
errors.extend(errs)
if not account.characters:
# this can happen for multisession_mode > 1. For guests we
# always auto-create a character, regardless of multi-session-mode.
character, errs = account.create_character()
if errs:
errors.extend(errs)
return account, errors
except Exception as e:
# We are in the middle between logged in and -not, so we have
# to handle tracebacks ourselves at this point. If we don't,
# we won't see any errors at all.
errors.append("An error occurred. Please e-mail an admin if the problem persists.")
errors.append(_("An error occurred. Please e-mail an admin if the problem persists."))
logger.log_trace()
return None, errors
@ -1572,7 +1649,7 @@ class DefaultGuest(DefaultAccount):
overriding the call (unused by default).
"""
self._send_to_connect_channel(f"|G{self.key} connected|n")
self._send_to_connect_channel(_("|G{key} connected|n").format(key=self.key))
self.puppet_object(session, self.db._last_puppet)
def at_server_shutdown(self):

View file

@ -4,13 +4,26 @@
#
from django import forms
from django.conf import settings
from django.contrib import admin
from django.contrib import admin, messages
from django.contrib.admin.options import IS_POPUP_VAR
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
from django.contrib.admin.utils import unquote
from django.template.response import TemplateResponse
from django.http import Http404, HttpResponseRedirect
from django.core.exceptions import PermissionDenied
from django.views.decorators.debug import sensitive_post_parameters
from django.utils.decorators import method_decorator
from django.utils.html import escape
from django.urls import path, reverse
from django.contrib.auth import update_session_auth_hash
from evennia.accounts.models import AccountDB
from evennia.typeclasses.admin import AttributeInline, TagInline
from evennia.utils import create
sensitive_post_parameters_m = method_decorator(sensitive_post_parameters())
# handle the custom User editor
class AccountDBChangeForm(UserChangeForm):
@ -88,6 +101,7 @@ class AccountForm(forms.ModelForm):
class Meta(object):
model = AccountDB
fields = "__all__"
app_label = "accounts"
db_key = forms.RegexField(
label="Username",
@ -259,6 +273,70 @@ class AccountDBAdmin(BaseUserAdmin):
),
)
@sensitive_post_parameters_m
def user_change_password(self, request, id, form_url=""):
user = self.get_object(request, unquote(id))
if not self.has_change_permission(request, user):
raise PermissionDenied
if user is None:
raise Http404("%(name)s object with primary key %(key)r does not exist.") % {
"name": self.model._meta.verbose_name,
"key": escape(id),
}
if request.method == "POST":
form = self.change_password_form(user, request.POST)
if form.is_valid():
form.save()
change_message = self.construct_change_message(request, form, None)
self.log_change(request, user, change_message)
msg = "Password changed successfully."
messages.success(request, msg)
update_session_auth_hash(request, form.user)
return HttpResponseRedirect(
reverse(
"%s:%s_%s_change"
% (
self.admin_site.name,
user._meta.app_label,
# the model_name is something we need to hardcode
# since our accountdb is a proxy:
"accountdb",
),
args=(user.pk,),
)
)
else:
form = self.change_password_form(user)
fieldsets = [(None, {"fields": list(form.base_fields)})]
adminForm = admin.helpers.AdminForm(form, fieldsets, {})
context = {
"title": "Change password: %s" % escape(user.get_username()),
"adminForm": adminForm,
"form_url": form_url,
"form": form,
"is_popup": (IS_POPUP_VAR in request.POST or IS_POPUP_VAR in request.GET),
"add": True,
"change": False,
"has_delete_permission": False,
"has_change_permission": True,
"has_absolute_url": False,
"opts": self.model._meta,
"original": user,
"save_as": False,
"show_save": True,
**self.admin_site.each_context(request),
}
request.current_app = self.admin_site.name
return TemplateResponse(
request,
self.change_user_password_template or "admin/auth/user/change_password.html",
context,
)
def save_model(self, request, obj, form, change):
"""
Custom save actions.

View file

@ -10,6 +10,7 @@ from evennia.accounts.accounts import DefaultAccount
from evennia.scripts.scripts import DefaultScript
from evennia.utils import search
from evennia.utils import utils
from django.utils.translation import gettext as _
_IDLE_TIMEOUT = settings.IDLE_TIMEOUT
@ -277,7 +278,7 @@ class IRCBot(Bot):
Args:
text (str, optional): Incoming text from channel.
Kwargs:
Keyword Args:
options (dict): Options dict with the following allowed keys:
- from_channel (str): dbid of a channel this text originated from.
- from_obj (list): list of objects sending this text.
@ -307,7 +308,7 @@ class IRCBot(Bot):
session (Session, optional): Session responsible for this
command. Note that this is the bot.
txt (str, optional): Command string.
Kwargs:
Keyword Args:
user (str): The name of the user who sent the message.
channel (str): The name of channel the message was sent to.
type (str): Nature of message. Either 'msg', 'action', 'nicklist'
@ -328,7 +329,9 @@ class IRCBot(Bot):
chstr = f"{self.db.irc_channel} ({self.db.irc_network}:{self.db.irc_port})"
nicklist = ", ".join(sorted(kwargs["nicklist"], key=lambda n: n.lower()))
for obj in self._nicklist_callers:
obj.msg(f"Nicks at {chstr}:\n {nicklist}")
obj.msg(
_("Nicks at {chstr}:\n {nicklist}").format(chstr=chstr, nicklist=nicklist)
)
self._nicklist_callers = []
return
@ -337,7 +340,11 @@ class IRCBot(Bot):
if hasattr(self, "_ping_callers") and self._ping_callers:
chstr = f"{self.db.irc_channel} ({self.db.irc_network}:{self.db.irc_port})"
for obj in self._ping_callers:
obj.msg(f"IRC ping return from {chstr} took {kwargs['timing']}s.")
obj.msg(
_("IRC ping return from {chstr} took {time}s.").format(
chstr=chstr, time=kwargs["timing"]
)
)
self._ping_callers = []
return
@ -511,7 +518,7 @@ class GrapevineBot(Bot):
Args:
text (str, optional): Incoming text from channel.
Kwargs:
Keyword Args:
options (dict): Options dict with the following allowed keys:
- from_channel (str): dbid of a channel this text originated from.
- from_obj (list): list of objects sending this text.

View file

@ -105,12 +105,12 @@ class AccountDB(TypedObject, AbstractUser):
objects = AccountDBManager()
# defaults
__settingsclasspath__ = settings.BASE_SCRIPT_TYPECLASS
__defaultclasspath__ = "evennia.accounts.accounts.DefaultAccount"
__applabel__ = "accounts"
__settingsclasspath__ = settings.BASE_SCRIPT_TYPECLASS
class Meta(object):
verbose_name = "Account"
# class Meta:
# verbose_name = "Account"
# cmdset_storage property
# This seems very sensitive to caching, so leaving it be for now /Griatch

View file

@ -92,7 +92,8 @@ class TestDefaultGuest(EvenniaTest):
@patch("evennia.accounts.accounts.ChannelDB.objects.get_channel")
def test_create(self, get_channel):
get_channel.connect = MagicMock(return_value=True)
account, errors = DefaultGuest.create()
with override_settings(GUEST_HOME=self.room1.dbref):
account, errors = DefaultGuest.create()
self.assertTrue(account, "Guest account should have been created.")
self.assertFalse(errors)

View file

View file

@ -48,7 +48,7 @@ from evennia.comms.channelhandler import CHANNELHANDLER
from evennia.utils import logger, utils
from evennia.utils.utils import string_suggestions
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
_IN_GAME_ERRORS = settings.IN_GAME_ERRORS
@ -174,6 +174,27 @@ def _msg_err(receiver, stringtuple):
)
def _process_input(caller, prompt, result, cmd, generator):
"""
Specifically handle the get_input value to send to _progressive_cmd_run as
part of yielding from a Command's `func`.
Args:
caller (Character, Account or Session): the caller.
prompt (str): The sent prompt.
result (str): The unprocessed answer.
cmd (Command): The command itself.
generator (GeneratorType): The generator.
Returns:
result (bool): Always `False` (stop processing).
"""
# We call it using a Twisted deferLater to make sure the input is properly closed.
deferLater(reactor, 0, _progressive_cmd_run, cmd, generator, response=result)
return False
def _progressive_cmd_run(cmd, generator, response=None):
"""
Progressively call the command that was given in argument. Used
@ -206,7 +227,15 @@ def _progressive_cmd_run(cmd, generator, response=None):
else:
value = generator.send(response)
except StopIteration:
pass
# duplicated from cmdhandler._run_command, to have these
# run in the right order while staying inside the deferred
cmd.at_post_cmd()
if cmd.save_for_next:
# store a reference to this command, possibly
# accessible by the next command.
cmd.caller.ndb.last_cmd = copy(cmd)
else:
cmd.caller.ndb.last_cmd = None
else:
if isinstance(value, (int, float)):
utils.delay(value, _progressive_cmd_run, cmd, generator)
@ -216,27 +245,6 @@ def _progressive_cmd_run(cmd, generator, response=None):
raise ValueError("unknown type for a yielded value in command: {}".format(type(value)))
def _process_input(caller, prompt, result, cmd, generator):
"""
Specifically handle the get_input value to send to _progressive_cmd_run as
part of yielding from a Command's `func`.
Args:
caller (Character, Account or Session): the caller.
prompt (str): The sent prompt.
result (str): The unprocessed answer.
cmd (Command): The command itself.
generator (GeneratorType): The generator.
Returns:
result (bool): Always `False` (stop processing).
"""
# We call it using a Twisted deferLater to make sure the input is properly closed.
deferLater(reactor, 0, _progressive_cmd_run, cmd, generator, response=result)
return False
# custom Exceptions
@ -472,13 +480,13 @@ def get_and_merge_cmdsets(caller, session, account, obj, callertype, raw_string)
tempmergers[prio] = cmdset
# sort cmdsets after reverse priority (highest prio are merged in last)
cmdsets = yield sorted(list(tempmergers.values()), key=lambda x: x.priority)
sorted_cmdsets = yield sorted(list(tempmergers.values()), key=lambda x: x.priority)
# Merge all command sets into one, beginning with the lowest-prio one
cmdset = cmdsets[0]
for merging_cmdset in cmdsets[1:]:
cmdset = sorted_cmdsets[0]
for merging_cmdset in sorted_cmdsets[1:]:
cmdset = yield cmdset + merging_cmdset
# store the full sets for diagnosis
# store the original, ungrouped set for diagnosis
cmdset.merged_from = cmdsets
# cache
_CMDSET_MERGE_CACHE[mergehash] = cmdset
@ -486,6 +494,11 @@ def get_and_merge_cmdsets(caller, session, account, obj, callertype, raw_string)
cmdset = None
for cset in (cset for cset in local_obj_cmdsets if cset):
cset.duplicates = cset.old_duplicates
# important - this syncs the CmdSetHandler's .current field with the
# true current cmdset!
if cmdset:
caller.cmdset.current = cmdset
returnValue(cmdset)
except ErrorReported:
raise
@ -540,7 +553,7 @@ def cmdhandler(
is made available as `self.cmdstring` when the Command runs.
If not given, the command will be assumed to be called as `cmdobj.key`.
Kwargs:
Keyword Args:
kwargs (any): other keyword arguments will be assigned as named variables on the
retrieved command object *before* it is executed. This is unused
in default Evennia but may be used by code to set custom flags or
@ -632,19 +645,23 @@ def cmdhandler(
if isinstance(ret, types.GeneratorType):
# cmd.func() is a generator, execute progressively
_progressive_cmd_run(cmd, ret)
yield None
ret = yield ret
# note that the _progressive_cmd_run will itself run
# the at_post_cmd etc as it finishes; this is a bit of
# code duplication but there seems to be no way to
# catch the StopIteration here (it's not in the same
# frame since this is in a deferred chain)
else:
ret = yield ret
# post-command hook
yield cmd.at_post_cmd()
# post-command hook
yield cmd.at_post_cmd()
if cmd.save_for_next:
# store a reference to this command, possibly
# accessible by the next command.
caller.ndb.last_cmd = yield copy(cmd)
else:
caller.ndb.last_cmd = None
if cmd.save_for_next:
# store a reference to this command, possibly
# accessible by the next command.
caller.ndb.last_cmd = yield copy(cmd)
else:
caller.ndb.last_cmd = None
# return result to the deferred
returnValue(ret)
@ -733,7 +750,7 @@ def cmdhandler(
if len(matches) == 1:
# We have a unique command match. But it may still be invalid.
match = matches[0]
cmdname, args, cmd, raw_cmdname = match[0], match[1], match[2], match[5]
cmdname, args, cmd, raw_cmdname = (match[0], match[1], match[2], match[5])
if not matches:
# No commands match our entered command
@ -743,7 +760,9 @@ def cmdhandler(
sysarg = raw_string
else:
# fallback to default error text
sysarg = _("Command '%s' is not available.") % raw_string
sysarg = _("Command '{command}' is not available.").format(
command=raw_string
)
suggestions = string_suggestions(
raw_string,
cmdset.get_all_cmd_keys_and_aliases(caller),
@ -751,8 +770,8 @@ def cmdhandler(
maxnum=3,
)
if suggestions:
sysarg += _(" Maybe you meant %s?") % utils.list_to_string(
suggestions, _("or"), addquote=True
sysarg += _(" Maybe you meant {command}?").format(
command=utils.list_to_string(suggestions, _("or"), addquote=True)
)
else:
sysarg += _(' Type "help" for help.')

View file

@ -125,7 +125,7 @@ def try_num_prefixes(raw_string):
# the user might be trying to identify the command
# with a #num-command style syntax. We expect the regex to
# contain the groups "number" and "name".
mindex, new_raw_string = num_ref_match.group("number"), num_ref_match.group("name")
mindex, new_raw_string = (num_ref_match.group("number"), num_ref_match.group("name"))
return mindex, new_raw_string
else:
return None, None
@ -209,10 +209,15 @@ def cmdparser(raw_string, cmdset, caller, match_index=None):
quality = [mat[4] for mat in matches]
matches = matches[-quality.count(quality[-1]) :]
if len(matches) > 1 and match_index is not None and 0 < match_index <= len(matches):
if len(matches) > 1 and match_index is not None:
# We couldn't separate match by quality, but we have an
# index argument to tell us which match to use.
matches = [matches[match_index - 1]]
if 0 < match_index <= len(matches):
matches = [matches[match_index - 1]]
else:
# we tried to give an index outside of the range - this means
# a no-match
matches = []
# no matter what we have at this point, we have to return it.
return matches

View file

@ -27,7 +27,7 @@ Set theory.
"""
from weakref import WeakKeyDictionary
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from evennia.utils.utils import inherits_from, is_iter
__all__ = ("CmdSet",)
@ -106,9 +106,9 @@ class CmdSet(object, metaclass=_CmdSetMeta):
commands preference.
duplicates - determines what happens when two sets of equal
priority merge. Default has the first of them in the
priority merge (only). Defaults to None and has the first of them in the
merger (i.e. A above) automatically taking
precedence. But if allow_duplicates is true, the
precedence. But if `duplicates` is true, the
result will be a merger with more than one of each
name match. This will usually lead to the account
receiving a multiple-match error higher up the road,
@ -119,6 +119,16 @@ class CmdSet(object, metaclass=_CmdSetMeta):
select which ball to kick ... Allowing duplicates
only makes sense for Union and Intersect, the setting
is ignored for the other mergetypes.
Note that the `duplicates` flag is *not* propagated in
a cmdset merger. So `A + B = C` will result in
a cmdset with duplicate commands, but C.duplicates will
be `None`. For duplication to apply to a whole cmdset
stack merge, _all_ cmdsets in the stack must have
`.duplicates=True` set.
Finally, if a final cmdset has `.duplicates=None` (the normal
unless created alone with another value), the cmdhandler
will assume True for object-based cmdsets and False for
all other. This is usually the most intuitive outcome.
key_mergetype (dict) - allows the cmdset to define a unique
mergetype for particular cmdsets. Format is
@ -144,14 +154,27 @@ class CmdSet(object, metaclass=_CmdSetMeta):
mergetype = "Union"
priority = 0
# These flags, if set to None, will allow "pass-through" of lower-prio settings
# of True/False. If set to True/False, will override lower-prio settings.
# These flags, if set to None should be interpreted as 'I don't care' and,
# will allow "pass-through" even of lower-prio cmdsets' explicitly True/False
# options. If this is set to True/False however, priority matters.
no_exits = None
no_objs = None
no_channels = None
# same as above, but if left at None in the final merged set, the
# cmdhandler will auto-assume True for Objects and stay False for all
# other entities.
# The .duplicates setting does not propagate and since duplicates can only happen
# on same-prio cmdsets, there is no concept of passthrough on `None`.
# The merger of two cmdsets always return in a cmdset with `duplicates=None`
# (even if the result may have duplicated commands).
# If a final cmdset has `duplicates=None` (normal, unless the cmdset is
# created on its own with the flag set), the cmdhandler will auto-assume it to be
# True for Object-based cmdsets and stay None/False for all other entities.
#
# Example:
# A and C has .duplicates=True, B has .duplicates=None (or False)
# B + A = BA, where BA will have duplicate cmds, but BA.duplicates = None
# BA + C = BAC, where BAC will have more duplication, but BAC.duplicates = None
#
# Basically, for the `.duplicate` setting to survive throughout a
# merge-stack, every cmdset in the stack must have `duplicates` set explicitly.
duplicates = None
permanent = False
@ -334,7 +357,19 @@ class CmdSet(object, metaclass=_CmdSetMeta):
commands (str): Representation of commands in Cmdset.
"""
return ", ".join([str(cmd) for cmd in sorted(self.commands, key=lambda o: o.key)])
perm = "perm" if self.permanent else "non-perm"
options = ", ".join(
[
"{}:{}".format(opt, "T" if getattr(self, opt) else "F")
for opt in ("no_exits", "no_objs", "no_channels", "duplicates")
if getattr(self, opt) is not None
]
)
options = (", " + options) if options else ""
return (
f"<CmdSet {self.key}, {self.mergetype}, {perm}, prio {self.priority}{options}>: "
+ ", ".join([str(cmd) for cmd in sorted(self.commands, key=lambda o: o.key)])
)
def __iter__(self):
"""
@ -401,12 +436,15 @@ class CmdSet(object, metaclass=_CmdSetMeta):
# pass through options whenever they are set, unless the merging or higher-prio
# set changes the setting (i.e. has a non-None value). We don't pass through
# the duplicates setting; that is per-merge
# the duplicates setting; that is per-merge; the resulting .duplicates value
# is always None (so merging cmdsets must all have explicit values if wanting
# to cause duplicates).
cmdset_c.no_channels = (
self.no_channels if cmdset_a.no_channels is None else cmdset_a.no_channels
)
cmdset_c.no_exits = self.no_exits if cmdset_a.no_exits is None else cmdset_a.no_exits
cmdset_c.no_objs = self.no_objs if cmdset_a.no_objs is None else cmdset_a.no_objs
cmdset_c.duplicates = None
else:
# B higher priority than A
@ -428,12 +466,15 @@ class CmdSet(object, metaclass=_CmdSetMeta):
# pass through options whenever they are set, unless the higher-prio
# set changes the setting (i.e. has a non-None value). We don't pass through
# the duplicates setting; that is per-merge
# the duplicates setting; that is per-merge; the resulting .duplicates value#
# is always None (so merging cmdsets must all have explicit values if wanting
# to cause duplicates).
cmdset_c.no_channels = (
cmdset_a.no_channels if self.no_channels is None else self.no_channels
)
cmdset_c.no_exits = cmdset_a.no_exits if self.no_exits is None else self.no_exits
cmdset_c.no_objs = cmdset_a.no_objs if self.no_objs is None else self.no_objs
cmdset_c.duplicates = None
# we store actual_mergetype since key_mergetypes
# might be different from the main mergetype.
@ -443,12 +484,12 @@ class CmdSet(object, metaclass=_CmdSetMeta):
# print "__add__ for %s (prio %i) called with %s (prio %i)." % (self.key, self.priority, cmdset_a.key, cmdset_a.priority)
# return the system commands to the cmdset
cmdset_c.add(sys_commands)
cmdset_c.add(sys_commands, allow_duplicates=True)
return cmdset_c
def add(self, cmd):
def add(self, cmd, allow_duplicates=False):
"""
Add a new command or commands to this CmdSetcommand, a list of
Add a new command or commands to this CmdSet, a list of
commands or a cmdset to this cmdset. Note that this is *not*
a merge operation (that is handled by the + operator).
@ -456,6 +497,9 @@ class CmdSet(object, metaclass=_CmdSetMeta):
cmd (Command, list, Cmdset): This allows for adding one or
more commands to this Cmdset in one go. If another Cmdset
is given, all its commands will be added.
allow_duplicates (bool, optional): If set, will not try to remove
duplicate cmds in the set. This is needed during the merge process
to avoid wiping commands coming from cmdsets with duplicate=True.
Notes:
If cmd already exists in set, it will replace the old one
@ -474,15 +518,16 @@ class CmdSet(object, metaclass=_CmdSetMeta):
# cmd is a command set so merge all commands in that set
# to this one. We raise a visible error if we created
# an infinite loop (adding cmdset to itself somehow)
cmdset = cmd
try:
cmd = self._instantiate(cmd)
cmdset = self._instantiate(cmdset)
except RuntimeError:
string = "Adding cmdset %(cmd)s to %(class)s lead to an "
string += "infinite loop. When adding a cmdset to another, "
string += "make sure they are not themself cyclically added to "
string += "the new cmdset somewhere in the chain."
raise RuntimeError(_(string) % {"cmd": cmd, "class": self.__class__})
cmds = cmd.commands
err = ("Adding cmdset {cmdset} to {cls} lead to an "
"infinite loop. When adding a cmdset to another, "
"make sure they are not themself cyclically added to "
"the new cmdset somewhere in the chain.")
raise RuntimeError(_(err.format(cmdset=cmdset, cls=self.__class__)))
cmds = cmdset.commands
elif is_iter(cmd):
cmds = [self._instantiate(c) for c in cmd]
else:
@ -491,15 +536,17 @@ class CmdSet(object, metaclass=_CmdSetMeta):
system_commands = self.system_commands
for cmd in cmds:
# add all commands
if not hasattr(cmd, "obj"):
if not hasattr(cmd, "obj") or cmd.obj is None:
cmd.obj = self.cmdsetobj
try:
ic = commands.index(cmd)
commands[ic] = cmd # replace
except ValueError:
commands.append(cmd)
# extra run to make sure to avoid doublets
self.commands = list(set(commands))
self.commands = commands
if not allow_duplicates:
# extra run to make sure to avoid doublets
self.commands = list(set(self.commands))
# add system_command to separate list as well,
# for quick look-up
if cmd.key.startswith("__"):

View file

@ -72,7 +72,7 @@ from evennia.utils import logger, utils
from evennia.commands.cmdset import CmdSet
from evennia.server.models import ServerConfig
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
__all__ = ("import_cmdset", "CmdSetHandler")
@ -184,7 +184,9 @@ def import_cmdset(path, cmdsetobj, emit_to_obj=None, no_logging=False):
raise exc.with_traceback(tb)
else:
# try next suggested path
errstring += _("\n(Unsuccessfully tried '%s')." % python_path)
errstring += _("\n(Unsuccessfully tried '{path}').").format(
path=python_path
)
continue
try:
cmdsetclass = getattr(module, classname)
@ -194,7 +196,9 @@ def import_cmdset(path, cmdsetobj, emit_to_obj=None, no_logging=False):
dum, dum, tb = sys.exc_info()
raise exc.with_traceback(tb)
else:
errstring += _("\n(Unsuccessfully tried '%s')." % python_path)
errstring += _("\n(Unsuccessfully tried '{path}').").format(
path=python_path
)
continue
_CACHED_CMDSETS[python_path] = cmdsetclass
@ -289,7 +293,10 @@ class CmdSetHandler(object):
# the id of the "merged" current cmdset for easy access.
self.key = None
# this holds the "merged" current command set
# this holds the "merged" current command set. Note that while the .update
# method updates this field in order to have it synced when operating on
# cmdsets in-code, when the game runs, this field is kept up-to-date by
# the cmdsethandler's get_and_merge_cmdsets!
self.current = None
# this holds a history of CommandSets
self.cmdset_stack = [_EmptyCmdSet(cmdsetobj=self.obj)]
@ -307,27 +314,13 @@ class CmdSetHandler(object):
Display current commands
"""
string = ""
strings = ["<CmdSetHandler> stack:"]
mergelist = []
if len(self.cmdset_stack) > 1:
# We have more than one cmdset in stack; list them all
for snum, cmdset in enumerate(self.cmdset_stack):
mergetype = self.mergetype_stack[snum]
permstring = "non-perm"
if cmdset.permanent:
permstring = "perm"
if mergetype != cmdset.mergetype:
mergetype = "%s^" % (mergetype)
string += "\n %i: <%s (%s, prio %i, %s)>: %s" % (
snum,
cmdset.key,
mergetype,
cmdset.priority,
permstring,
cmdset,
)
mergelist.append(str(snum))
string += "\n"
mergelist.append(str(snum + 1))
strings.append(f" {snum + 1}: {cmdset}")
# Display the currently active cmdset, limited by self.obj's permissions
mergetype = self.mergetype_stack[-1]
@ -335,27 +328,15 @@ class CmdSetHandler(object):
merged_on = self.cmdset_stack[-2].key
mergetype = _("custom {mergetype} on cmdset '{cmdset}'")
mergetype = mergetype.format(mergetype=mergetype, cmdset=merged_on)
if mergelist:
tmpstring = _(" <Merged {mergelist} {mergetype}, prio {prio}>: {current}")
string += tmpstring.format(
mergelist="+".join(mergelist),
mergetype=mergetype,
prio=self.current.priority,
current=self.current,
)
# current is a result of mergers
mergelist = "+".join(mergelist)
strings.append(f" <Merged {mergelist}>: {self.current}")
else:
permstring = "non-perm"
if self.current.permanent:
permstring = "perm"
tmpstring = _(" <{key} ({mergetype}, prio {prio}, {permstring})>:\n {keylist}")
string += tmpstring.format(
key=self.current.key,
mergetype=mergetype,
prio=self.current.priority,
permstring=permstring,
keylist=", ".join(cmd.key for cmd in sorted(self.current, key=lambda o: o.key)),
)
return string.strip()
# current is a single cmdset
strings.append(" " + str(self.current))
return "\n".join(strings).rstrip()
def _import_cmdset(self, cmdset_path, emit_to_obj=None):
"""
@ -377,12 +358,22 @@ class CmdSetHandler(object):
def update(self, init_mode=False):
"""
Re-adds all sets in the handler to have an updated current
set.
Args:
init_mode (bool, optional): Used automatically right after
this handler was created; it imports all permanent cmdsets
from the database.
Notes:
This method is necessary in order to always have a `.current`
cmdset when working with the cmdsethandler in code. But the
CmdSetHandler doesn't (cannot) consider external cmdsets and game
state. This means that the .current calculated from this method
will likely not match the true current cmdset as determined at
run-time by `cmdhandler.get_and_merge_cmdsets()`. So in a running
game the responsibility of keeping `.current` upt-to-date belongs
to the central `cmdhandler.get_and_merge_cmdsets()`!
"""
if init_mode:
# reimport all permanent cmdsets

View file

@ -6,6 +6,7 @@ All commands in Evennia inherit from the 'Command' class in this module.
"""
import re
import math
import inspect
from django.conf import settings
@ -74,6 +75,13 @@ def _init_command(cls, **kwargs):
cls.is_exit = False
if not hasattr(cls, "help_category"):
cls.help_category = "general"
# make sure to pick up the parent's docstring if the child class is
# missing one (important for auto-help)
if cls.__doc__ is None:
for parent_class in inspect.getmro(cls):
if parent_class.__doc__ is not None:
cls.__doc__ = parent_class.__doc__
break
cls.help_category = cls.help_category.lower()
@ -130,6 +138,9 @@ class Command(object, metaclass=CommandMeta):
arg_regex - (optional) raw string regex defining how the argument part of
the command should look in order to match for this command
(e.g. must it be a space between cmdname and arg?)
auto_help_display_key - (optional) if given, this replaces the string shown
in the auto-help listing. This is particularly useful for system-commands
whose actual key is not really meaningful.
(Note that if auto_help is on, this initial string is also used by the
system to create the help entry for the command, so it's a good idea to
@ -332,7 +343,7 @@ class Command(object, metaclass=CommandMeta):
session (Session, optional): Supply data only to a unique
session (ignores the value of `self.msg_all_sessions`).
Kwargs:
Keyword Args:
options (dict): Options to the protocol.
any (any): All other keywords are interpreted as th
name of send-instructions.
@ -358,7 +369,7 @@ class Command(object, metaclass=CommandMeta):
obj (Object or Account, optional): Object or Account on which to call the execute_cmd.
If not given, self.caller will be used.
Kwargs:
Keyword Args:
Other keyword arguments will be added to the found command
object instace as variables before it executes. This is
unused by default Evennia but may be used to set flags and
@ -401,12 +412,11 @@ class Command(object, metaclass=CommandMeta):
"""
pass
def func(self):
def get_command_info(self):
"""
This is the actual executing part of the command. It is
called directly after self.parse(). See the docstring of this
module for which object properties are available (beyond those
set in self.parse())
This is the default output of func() if no func() overload is done.
Provided here as a separate method so that it can be called for debugging
purposes when making commands.
"""
variables = "\n".join(
@ -416,11 +426,8 @@ class Command(object, metaclass=CommandMeta):
Command {self} has no defined `func()` - showing on-command variables:
{variables}
"""
self.caller.msg(string)
return
# a simple test command to show the available properties
string = "-" * 50
string += "-" * 50
string += "\n|w%s|n - Command variables from evennia:\n" % self.key
string += "-" * 50
string += "\nname of cmd (self.key): |w%s|n\n" % self.key
@ -438,6 +445,16 @@ Command {self} has no defined `func()` - showing on-command variables:
self.caller.msg(string)
def func(self):
"""
This is the actual executing part of the command. It is
called directly after self.parse(). See the docstring of this
module for which object properties are available (beyond those
set in self.parse())
"""
self.get_command_info()
def get_extra_info(self, caller, **kwargs):
"""
Display some extra information that may help distinguish this
@ -484,12 +501,28 @@ Command {self} has no defined `func()` - showing on-command variables:
Get the client screenwidth for the session using this command.
Returns:
client width (int or None): The width (in characters) of the client window. None
if this command is run without a Session (such as by an NPC).
client width (int): The width (in characters) of the client window.
"""
if self.session:
return self.session.protocol_flags["SCREENWIDTH"][0]
return self.session.protocol_flags.get(
"SCREENWIDTH", {0: settings.CLIENT_DEFAULT_WIDTH}
)[0]
return settings.CLIENT_DEFAULT_WIDTH
def client_height(self):
"""
Get the client screenheight for the session using this command.
Returns:
client height (int): The height (in characters) of the client window.
"""
if self.session:
return self.session.protocol_flags.get(
"SCREENHEIGHT", {0: settings.CLIENT_DEFAULT_HEIGHT}
)[0]
return settings.CLIENT_DEFAULT_HEIGHT
def styled_table(self, *args, **kwargs):
"""
@ -498,7 +531,7 @@ Command {self} has no defined `func()` - showing on-command variables:
Args:
*args (str): Column headers. If not colored explicitly, these will get colors
from user options.
Kwargs:
Keyword Args:
any (str, int or dict): EvTable options, including, optionally a `table` dict
detailing the contents of the table.
Returns:
@ -551,7 +584,7 @@ Command {self} has no defined `func()` - showing on-command variables:
"""
Helper for formatting a string into a pretty display, for a header, separator or footer.
Kwargs:
Keyword Args:
header_text (str): Text to include in header.
fill_character (str): This single character will be used to fill the width of the
display.

View file

@ -154,7 +154,8 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS):
if not account.is_superuser and (
account.db._playable_characters and len(account.db._playable_characters) >= charmax
):
self.msg("You may only create a maximum of %i characters." % charmax)
plural = "" if charmax == 1 else "s"
self.msg(f"You may only create a maximum of {charmax} character{plural}.")
return
from evennia.objects.models import ObjectDB
@ -300,27 +301,68 @@ class CmdIC(COMMAND_DEFAULT_CLASS):
session = self.session
new_character = None
character_candidates = []
if not self.args:
new_character = account.db._last_puppet
if not new_character:
character_candidates = [account.db._last_puppet] if account.db._last_puppet else []
if not character_candidates:
self.msg("Usage: ic <character>")
return
if not new_character:
# search for a matching character
new_character = [
char for char in search.object_search(self.args) if char.access(account, "puppet")
]
if not new_character:
self.msg("That is not a valid character choice.")
return
if len(new_character) > 1:
self.msg(
"Multiple targets with the same name:\n %s"
% ", ".join("%s(#%s)" % (obj.key, obj.id) for obj in new_character)
else:
# argument given
if account.db._playable_characters:
# look at the playable_characters list first
character_candidates.extend(
account.search(
self.args,
candidates=account.db._playable_characters,
search_object=True,
quiet=True,
)
)
return
else:
new_character = new_character[0]
if account.locks.check_lockstring(account, "perm(Builder)"):
# builders and higher should be able to puppet more than their
# playable characters.
if session.puppet:
# start by local search - this helps to avoid the user
# getting locked into their playable characters should one
# happen to be named the same as another. We replace the suggestion
# from playable_characters here - this allows builders to puppet objects
# with the same name as their playable chars should it be necessary
# (by going to the same location).
character_candidates = [
char
for char in session.puppet.search(self.args, quiet=True)
if char.access(account, "puppet")
]
if not character_candidates:
# fall back to global search only if Builder+ has no
# playable_characers in list and is not standing in a room
# with a matching char.
character_candidates.extend(
[
char
for char in search.object_search(self.args)
if char.access(account, "puppet")
]
)
# handle possible candidates
if not character_candidates:
self.msg("That is not a valid character choice.")
return
if len(character_candidates) > 1:
self.msg(
"Multiple targets with the same name:\n %s"
% ", ".join("%s(#%s)" % (obj.key, obj.id) for obj in character_candidates)
)
return
else:
new_character = character_candidates[0]
# do the puppet puppet
try:
account.puppet_object(session, new_character)
account.db._last_puppet = new_character
@ -957,7 +999,7 @@ class CmdQuell(COMMAND_DEFAULT_CLASS):
self.msg("Already quelling Account %s permissions." % permstr)
return
account.attributes.add("_quell", True)
puppet = self.session.puppet
puppet = self.session.puppet if self.session else None
if puppet:
cpermstr = "(%s)" % ", ".join(puppet.permissions.all())
cpermstr = "Quelling to current puppet's permissions %s." % cpermstr

View file

@ -50,35 +50,6 @@ _UTF8_ERROR = """
Error reported was: '%s'
"""
_PROCPOOL_BATCHCMD_SOURCE = """
from evennia.commands.default.batchprocess import batch_cmd_exec, step_pointer, BatchSafeCmdSet
caller.ndb.batch_stack = commands
caller.ndb.batch_stackptr = 0
caller.ndb.batch_batchmode = "batch_commands"
caller.cmdset.add(BatchSafeCmdSet)
for inum in range(len(commands)):
print "command:", inum
caller.cmdset.add(BatchSafeCmdSet)
if not batch_cmd_exec(caller):
break
step_pointer(caller, 1)
print "leaving run ..."
"""
_PROCPOOL_BATCHCODE_SOURCE = """
from evennia.commands.default.batchprocess import batch_code_exec, step_pointer, BatchSafeCmdSet
caller.ndb.batch_stack = codes
caller.ndb.batch_stackptr = 0
caller.ndb.batch_batchmode = "batch_code"
caller.cmdset.add(BatchSafeCmdSet)
for inum in range(len(codes)):
print "code:", inum
caller.cmdset.add(BatchSafeCmdSet)
if not batch_code_exec(caller):
break
step_pointer(caller, 1)
print "leaving run ..."
"""
# -------------------------------------------------------------
# Helper functions
@ -300,42 +271,17 @@ class CmdBatchCommands(_COMMAND_DEFAULT_CLASS):
"for %s (this might take some time) ..." % python_path
)
procpool = False
if "PythonProcPool" in utils.server_services():
if utils.uses_database("sqlite3"):
caller.msg("Batchprocessor disabled ProcPool under SQLite3.")
else:
procpool = True
if procpool:
# run in parallel process
def callback(r):
caller.msg(" |GBatchfile '%s' applied." % python_path)
purge_processor(caller)
def errback(e):
caller.msg(" |RError from processor: '%s'" % e)
purge_processor(caller)
utils.run_async(
_PROCPOOL_BATCHCMD_SOURCE,
commands=commands,
caller=caller,
at_return=callback,
at_err=errback,
)
else:
# run in-process (might block)
for _ in range(len(commands)):
# loop through the batch file
if not batch_cmd_exec(caller):
return
step_pointer(caller, 1)
# clean out the safety cmdset and clean out all other
# temporary attrs.
string = " Batchfile '%s' applied." % python_path
caller.msg("|G%s" % string)
purge_processor(caller)
# run in-process (might block)
for _ in range(len(commands)):
# loop through the batch file
if not batch_cmd_exec(caller):
return
step_pointer(caller, 1)
# clean out the safety cmdset and clean out all other
# temporary attrs.
string = " Batchfile '%s' applied." % python_path
caller.msg("|G%s" % string)
purge_processor(caller)
class CmdBatchCode(_COMMAND_DEFAULT_CLASS):
@ -420,41 +366,16 @@ class CmdBatchCode(_COMMAND_DEFAULT_CLASS):
else:
caller.msg("Running Batch-code processor - Automatic mode for %s ..." % python_path)
procpool = False
if "PythonProcPool" in utils.server_services():
if utils.uses_database("sqlite3"):
caller.msg("Batchprocessor disabled ProcPool under SQLite3.")
else:
procpool = True
if procpool:
# run in parallel process
def callback(r):
caller.msg(" |GBatchfile '%s' applied." % python_path)
purge_processor(caller)
def errback(e):
caller.msg(" |RError from processor: '%s'" % e)
purge_processor(caller)
utils.run_async(
_PROCPOOL_BATCHCODE_SOURCE,
codes=codes,
caller=caller,
at_return=callback,
at_err=errback,
)
else:
# un in-process (will block)
for _ in range(len(codes)):
# loop through the batch file
if not batch_code_exec(caller):
return
step_pointer(caller, 1)
# clean out the safety cmdset and clean out all other
# temporary attrs.
string = " Batchfile '%s' applied." % python_path
caller.msg("|G%s" % string)
purge_processor(caller)
for _ in range(len(codes)):
# loop through the batch file
if not batch_code_exec(caller):
return
step_pointer(caller, 1)
# clean out the safety cmdset and clean out all other
# temporary attrs.
string = " Batchfile '%s' applied." % python_path
caller.msg("|G%s" % string)
purge_processor(caller)
# -------------------------------------------------------------

File diff suppressed because it is too large Load diff

View file

@ -37,7 +37,9 @@ __all__ = (
"CmdCdesc",
"CmdPage",
"CmdIRC2Chan",
"CmdIRCStatus",
"CmdRSS2Chan",
"CmdGrapevine2Chan",
)
_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
@ -806,17 +808,48 @@ class CmdPage(COMMAND_DEFAULT_CLASS):
lastpages = pages[-number:]
else:
lastpages = pages
template = "|w%s|n |c%s|n to |c%s|n: %s"
lastpages = "\n ".join(
template
% (
utils.datetime_format(page.date_created),
",".join(obj.key for obj in page.senders),
"|n,|c ".join([obj.name for obj in page.receivers]),
page.message,
to_template = "|w{date}{clr} {sender}|nto{clr}{receiver}|n:> {message}"
from_template = "|w{date}{clr} {receiver}|nfrom{clr}{sender}|n:< {message}"
listing = []
prev_selfsend = False
for page in lastpages:
multi_send = len(page.senders) > 1
multi_recv = len(page.receivers) > 1
sending = self.caller in page.senders
# self-messages all look like sends, so we assume they always
# come in close pairs and treat the second of the pair as the recv.
selfsend = sending and self.caller in page.receivers
if selfsend:
if prev_selfsend:
# this is actually a receive of a self-message
sending = False
prev_selfsend = False
else:
prev_selfsend = True
clr = "|c" if sending else "|g"
sender = f"|n,{clr}".join(obj.key for obj in page.senders)
receiver = f"|n,{clr}".join([obj.name for obj in page.receivers])
if sending:
template = to_template
sender = f"{sender} " if multi_send else ""
receiver = f" {receiver}" if multi_recv else f" {receiver}"
else:
template = from_template
receiver = f"{receiver} " if multi_recv else ""
sender = f" {sender} " if multi_send else f" {sender}"
listing.append(
template.format(
date=utils.datetime_format(page.date_created),
clr=clr,
sender=sender,
receiver=receiver,
message=page.message,
)
)
for page in lastpages
)
lastpages = "\n ".join(listing)
if lastpages:
string = "Your latest pages:\n %s" % lastpages

View file

@ -378,10 +378,15 @@ class CmdInventory(COMMAND_DEFAULT_CLASS):
if not items:
string = "You are not carrying anything."
else:
from evennia.utils.ansi import raw as raw_ansi
table = self.styled_table(border="header")
for item in items:
table.add_row("|C%s|n" % item.name, item.db.desc or "")
string = "|wYou are carrying:\n%s" % table
table.add_row(
f"|C{item.name}|n",
"{}|n".format(utils.crop(raw_ansi(item.db.desc), width=50) or ""),
)
string = f"|wYou are carrying:\n{table}"
self.caller.msg(string)
@ -426,11 +431,16 @@ class CmdGet(COMMAND_DEFAULT_CLASS):
if not obj.at_before_get(caller):
return
obj.move_to(caller, quiet=True)
caller.msg("You pick up %s." % obj.name)
caller.location.msg_contents("%s picks up %s." % (caller.name, obj.name), exclude=caller)
# calling at_get hook method
obj.at_get(caller)
success = obj.move_to(caller, quiet=True)
if not success:
caller.msg("This can't be picked up.")
else:
caller.msg("You pick up %s." % obj.name)
caller.location.msg_contents(
"%s picks up %s." % (caller.name, obj.name), exclude=caller
)
# calling at_get hook method
obj.at_get(caller)
class CmdDrop(COMMAND_DEFAULT_CLASS):
@ -471,11 +481,14 @@ class CmdDrop(COMMAND_DEFAULT_CLASS):
if not obj.at_before_drop(caller):
return
obj.move_to(caller.location, quiet=True)
caller.msg("You drop %s." % (obj.name,))
caller.location.msg_contents("%s drops %s." % (caller.name, obj.name), exclude=caller)
# Call the object script's at_drop() method.
obj.at_drop(caller)
success = obj.move_to(caller.location, quiet=True)
if not success:
caller.msg("This couldn't be dropped.")
else:
caller.msg("You drop %s." % (obj.name,))
caller.location.msg_contents("%s drops %s." % (caller.name, obj.name), exclude=caller)
# Call the object script's at_drop() method.
obj.at_drop(caller)
class CmdGive(COMMAND_DEFAULT_CLASS):
@ -522,11 +535,14 @@ class CmdGive(COMMAND_DEFAULT_CLASS):
return
# give object
caller.msg("You give %s to %s." % (to_give.key, target.key))
to_give.move_to(target, quiet=True)
target.msg("%s gives you %s." % (caller.key, to_give.key))
# Call the object script's at_give() method.
to_give.at_give(caller, target)
success = to_give.move_to(target, quiet=True)
if not success:
caller.msg("This could not be given.")
else:
caller.msg("You give %s to %s." % (to_give.key, target.key))
target.msg("%s gives you %s." % (caller.key, to_give.key))
# Call the object script's at_give() method.
to_give.at_give(caller, target)
class CmdSetDesc(COMMAND_DEFAULT_CLASS):

View file

@ -25,7 +25,7 @@ _DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
_SEP = "|C" + "-" * _DEFAULT_WIDTH + "|n"
class CmdHelp(Command):
class CmdHelp(COMMAND_DEFAULT_CLASS):
"""
View help or a list of topics
@ -79,7 +79,7 @@ class CmdHelp(Command):
evmore.msg(self.caller, text, session=self.session)
return
self.msg((text, {"type": "help"}))
self.msg(text=(text, {"type": "help"}))
@staticmethod
def format_help_entry(title, help_text, aliases=None, suggested=None):
@ -175,7 +175,7 @@ class CmdHelp(Command):
False: the command shouldn't appear in the table.
"""
return True
return cmd.access(caller, "view", default=True)
def parse(self):
"""
@ -219,10 +219,15 @@ class CmdHelp(Command):
hdict_topic = defaultdict(list)
# create the dictionaries {category:[topic, topic ...]} required by format_help_list
# Filter commands that should be reached by the help
# system, but not be displayed in the table.
# system, but not be displayed in the table, or be displayed differently.
for cmd in all_cmds:
if self.should_list_cmd(cmd, caller):
hdict_cmd[cmd.help_category].append(cmd.key)
key = (
cmd.auto_help_display_key
if hasattr(cmd, "auto_help_display_key")
else cmd.key
)
hdict_cmd[cmd.help_category].append(key)
[hdict_topic[topic.help_category].append(topic.key) for topic in all_topics]
# report back
self.msg_help(self.format_help_list(hdict_cmd, hdict_topic))
@ -266,11 +271,10 @@ class CmdHelp(Command):
]
if len(match) == 1:
cmd = match[0]
key = cmd.auto_help_display_key if hasattr(cmd, "auto_help_display_key") else cmd.key
formatted = self.format_help_entry(
match[0].key,
match[0].get_help(caller, cmdset),
aliases=match[0].aliases,
suggested=suggestions,
key, cmd.get_help(caller, cmdset), aliases=cmd.aliases, suggested=suggestions,
)
self.msg_help(formatted)
return
@ -291,7 +295,15 @@ class CmdHelp(Command):
if query in all_categories:
self.msg_help(
self.format_help_list(
{query: [cmd.key for cmd in all_cmds if cmd.help_category == query]},
{
query: [
cmd.auto_help_display_key
if hasattr(cmd, "auto_help_display_key")
else cmd.key
for cmd in all_cmds
if cmd.help_category == query
]
},
{query: [topic.key for topic in all_topics if topic.help_category == query]},
)
)
@ -376,7 +388,7 @@ class CmdSetHelp(COMMAND_DEFAULT_CLASS):
self.msg("You have to define a topic!")
return
topicstrlist = topicstr.split(";")
topicstr, aliases = topicstrlist[0], topicstrlist[1:] if len(topicstr) > 1 else []
topicstr, aliases = (topicstrlist[0], topicstrlist[1:] if len(topicstr) > 1 else [])
aliastxt = ("(aliases: %s)" % ", ".join(aliases)) if aliases else ""
old_entry = None

View file

@ -202,11 +202,9 @@ class MuxCommand(Command):
else:
self.character = None
def func(self):
def get_command_info(self):
"""
This is the hook function that actually does all the work. It is called
by the cmdhandler right after self.parser() finishes, and so has access
to all the variables defined therein.
Update of parent class's get_command_info() for MuxCommand.
"""
variables = "\n".join(
" |w{}|n ({}): {}".format(key, type(val), val) for key, val in self.__dict__.items()
@ -245,6 +243,14 @@ Command {self} has no defined `func()` - showing on-command variables: No child
string += "-" * 50
self.caller.msg(string)
def func(self):
"""
This is the hook function that actually does all the work. It is called
by the cmdhandler right after self.parser() finishes, and so has access
to all the variables defined therein.
"""
self.get_command_info()
class MuxAccountCommand(MuxCommand):
"""

View file

@ -16,6 +16,7 @@ import twisted
import time
from django.conf import settings
from django.core.paginator import Paginator
from evennia.server.sessionhandler import SESSIONS
from evennia.scripts.models import ScriptDB
from evennia.objects.models import ObjectDB
@ -23,6 +24,7 @@ from evennia.accounts.models import AccountDB
from evennia.utils import logger, utils, gametime, create, search
from evennia.utils.eveditor import EvEditor
from evennia.utils.evtable import EvTable
from evennia.utils.evmore import EvMore
from evennia.utils.utils import crop, class_from_module
COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
@ -232,6 +234,10 @@ def _run_code_snippet(
if ret is None:
return
elif isinstance(ret, tuple):
# we must convert here to allow msg to pass it (a tuple is confused
# with a outputfunc structure)
ret = str(ret)
for session in sessions:
try:
@ -284,8 +290,6 @@ class EvenniaPythonConsole(code.InteractiveConsole):
result = None
try:
result = super().push(line)
except SystemExit:
pass
finally:
sys.stdout = old_stdout
sys.stderr = old_stderr
@ -301,6 +305,7 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
py/edit
py/time <cmd>
py/clientraw <cmd>
py/noecho
Switches:
time - output an approximate execution time for <cmd>
@ -308,6 +313,8 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
clientraw - turn off all client-specific escaping. Note that this may
lead to different output depending on prototocol (such as angular brackets
being parsed as HTML in the webclient but not in telnet clients)
noecho - in Python console mode, turn off the input echo (e.g. if your client
does this for you already)
Without argument, open a Python console in-game. This is a full console,
accepting multi-line Python code for testing and debugging. Type `exit()` to
@ -339,7 +346,7 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
key = "py"
aliases = ["!"]
switch_options = ("time", "edit", "clientraw")
switch_options = ("time", "edit", "clientraw", "noecho")
locks = "cmd:perm(py) or perm(Developer)"
help_category = "System"
@ -349,6 +356,8 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
caller = self.caller
pycode = self.args
noecho = "noecho" in self.switches
if "edit" in self.switches:
caller.db._py_measure_time = "time" in self.switches
caller.db._py_clientraw = "clientraw" in self.switches
@ -367,15 +376,26 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
# Run in interactive mode
console = EvenniaPythonConsole(self.caller)
banner = (
f"|gPython {sys.version} on {sys.platform}\n"
"Evennia interactive console mode - type 'exit()' to leave.|n"
"|gEvennia Interactive Python mode{echomode}\n"
"Python {version} on {platform}".format(
echomode=" (no echoing of prompts)" if noecho else "",
version=sys.version,
platform=sys.platform,
)
)
self.msg(banner)
line = ""
prompt = ">>>"
main_prompt = "|x[py mode - quit() to exit]|n"
prompt = main_prompt
while line.lower() not in ("exit", "exit()"):
line = yield (prompt)
prompt = "..." if console.push(line) else ">>>"
try:
line = yield (prompt)
if noecho:
prompt = "..." if console.push(line) else main_prompt
else:
prompt = line if console.push(line) else f"{line}\n{main_prompt}"
except SystemExit:
break
self.msg("|gClosing the Python console.|n")
return
@ -387,53 +407,71 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
)
# helper function. Kept outside so it can be imported and run
# by other commands.
class ScriptEvMore(EvMore):
"""
Listing 1000+ Scripts can be very slow and memory-consuming. So
we use this custom EvMore child to build en EvTable only for
each page of the list.
"""
def format_script_list(scripts):
"""Takes a list of scripts and formats the output."""
if not scripts:
return "<No scripts>"
def init_pages(self, scripts):
"""Prepare the script list pagination"""
script_pages = Paginator(scripts, max(1, int(self.height / 2)))
super().init_pages(script_pages)
table = EvTable(
"|wdbref|n",
"|wobj|n",
"|wkey|n",
"|wintval|n",
"|wnext|n",
"|wrept|n",
"|wdb",
"|wtypeclass|n",
"|wdesc|n",
align="r",
border="tablecols",
)
for script in scripts:
nextrep = script.time_until_next_repeat()
if nextrep is None:
nextrep = "PAUS" if script.db._paused_time else "--"
else:
nextrep = "%ss" % nextrep
def page_formatter(self, scripts):
"""Takes a page of scripts and formats the output
into an EvTable."""
maxrepeat = script.repeats
if maxrepeat:
rept = "%i/%i" % (maxrepeat - script.remaining_repeats(), maxrepeat)
else:
rept = "-/-"
if not scripts:
return "<No scripts>"
table.add_row(
script.id,
script.obj.key if (hasattr(script, "obj") and script.obj) else "<Global>",
script.key,
script.interval if script.interval > 0 else "--",
nextrep,
rept,
"*" if script.persistent else "-",
script.typeclass_path.rsplit(".", 1)[-1],
crop(script.desc, width=20),
table = EvTable(
"|wdbref|n",
"|wobj|n",
"|wkey|n",
"|wintval|n",
"|wnext|n",
"|wrept|n",
"|wdb",
"|wtypeclass|n",
"|wdesc|n",
align="r",
border="tablecols",
width=self.width,
)
return "%s" % table
for script in scripts:
nextrep = script.time_until_next_repeat()
if nextrep is None:
nextrep = "PAUSED" if script.db._paused_time else "--"
else:
nextrep = "%ss" % nextrep
maxrepeat = script.repeats
remaining = script.remaining_repeats() or 0
if maxrepeat:
rept = "%i/%i" % (maxrepeat - remaining, maxrepeat)
else:
rept = "-/-"
table.add_row(
script.id,
f"{script.obj.key}({script.obj.dbref})"
if (hasattr(script, "obj") and script.obj)
else "<Global>",
script.key,
script.interval if script.interval > 0 else "--",
nextrep,
rept,
"*" if script.persistent else "-",
script.typeclass_path.rsplit(".", 1)[-1],
crop(script.desc, width=20),
)
return str(table)
class CmdScripts(COMMAND_DEFAULT_CLASS):
@ -464,6 +502,8 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
locks = "cmd:perm(listscripts) or perm(Admin)"
help_category = "System"
excluded_typeclass_paths = ["evennia.prototypes.prototypes.DbPrototype"]
def func(self):
"""implement method"""
@ -496,6 +536,8 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
if not scripts:
caller.msg("No scripts are running.")
return
# filter any found scripts by tag category.
scripts = scripts.exclude(db_typeclass_path__in=self.excluded_typeclass_paths)
if not scripts:
string = "No scripts found with a key '%s', or on an object named '%s'." % (args, args)
@ -515,19 +557,20 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
# import pdb # DEBUG
# pdb.set_trace() # DEBUG
ScriptDB.objects.validate() # just to be sure all is synced
caller.msg(string)
else:
# multiple matches.
string = "Multiple script matches. Please refine your search:\n"
string += format_script_list(scripts)
ScriptEvMore(caller, scripts, session=self.session)
caller.msg("Multiple script matches. Please refine your search")
elif self.switches and self.switches[0] in ("validate", "valid", "val"):
# run validation on all found scripts
nr_started, nr_stopped = ScriptDB.objects.validate(scripts=scripts)
string = "Validated %s scripts. " % ScriptDB.objects.all().count()
string += "Started %s and stopped %s scripts." % (nr_started, nr_stopped)
caller.msg(string)
else:
# No stopping or validation. We just want to view things.
string = format_script_list(scripts)
caller.msg(string)
ScriptEvMore(caller, scripts.order_by("id"), session=self.session)
class CmdObjects(COMMAND_DEFAULT_CLASS):
@ -592,9 +635,13 @@ class CmdObjects(COMMAND_DEFAULT_CLASS):
"|wtypeclass|n", "|wcount|n", "|w%|n", border="table", align="l"
)
typetable.align = "l"
dbtotals = ObjectDB.objects.object_totals()
for path, count in dbtotals.items():
typetable.add_row(path, count, "%.2f" % ((float(count) / nobjs) * 100))
dbtotals = ObjectDB.objects.get_typeclass_totals()
for stat in dbtotals:
typetable.add_row(
stat.get("typeclass", "<error>"),
stat.get("count", -1),
"%.2f" % stat.get("percent", -1),
)
# last N table
objs = ObjectDB.objects.all().order_by("db_date_created")[max(0, nobjs - nlim) :]
@ -793,21 +840,30 @@ class CmdService(COMMAND_DEFAULT_CLASS):
return
if service.name[:7] == "Evennia":
if delmode:
caller.msg("You cannot remove a core Evennia service (named 'Evennia***').")
caller.msg("You cannot remove a core Evennia service (named 'Evennia*').")
return
string = "You seem to be shutting down a core Evennia service (named 'Evennia***'). Note that"
string += "stopping some TCP port services will *not* disconnect users *already*"
string += "connected on those ports, but *may* instead cause spurious errors for them. To "
string += "safely and permanently remove ports, change settings file and restart the server."
string = ("|RYou seem to be shutting down a core Evennia "
"service (named 'Evennia*').\nNote that stopping "
"some TCP port services will *not* disconnect users "
"*already* connected on those ports, but *may* "
"instead cause spurious errors for them.\nTo safely "
"and permanently remove ports, change settings file "
"and restart the server.|n\n")
caller.msg(string)
if delmode:
service.stopService()
service_collection.removeService(service)
caller.msg("Stopped and removed service '%s'." % self.args)
caller.msg("|gStopped and removed service '%s'.|n" % self.args)
else:
service.stopService()
caller.msg("Stopped service '%s'." % self.args)
caller.msg(f"Stopping service '{self.args}'...")
try:
service.stopService()
except Exception as err:
caller.msg(f"|rErrors were reported when stopping this service{err}.\n"
"If there are remaining problems, try reloading "
"or rebooting the server.")
caller.msg("|g... Stopped service '%s'.|n" % self.args)
return
if switches[0] == "start":
@ -815,8 +871,14 @@ class CmdService(COMMAND_DEFAULT_CLASS):
if service.running:
caller.msg("That service is already running.")
return
caller.msg("Starting service '%s'." % self.args)
service.startService()
caller.msg(f"Starting service '{self.args}' ...")
try:
service.startService()
except Exception as err:
caller.msg(f"|rErrors were reported when starting this service{err}.\n"
"If there are remaining problems, try reloading the server, changing the "
"settings if it's a non-standard service.|n")
caller.msg("|gService started.|n")
class CmdAbout(COMMAND_DEFAULT_CLASS):

View file

@ -17,9 +17,9 @@ import datetime
from anything import Anything
from django.conf import settings
from mock import Mock, mock
from unittest.mock import patch, Mock, MagicMock
from evennia import DefaultRoom, DefaultExit
from evennia import DefaultRoom, DefaultExit, ObjectDB
from evennia.commands.default.cmdset_character import CharacterCmdSet
from evennia.utils.test_resources import EvenniaTest
from evennia.commands.default import (
@ -56,6 +56,7 @@ _RE = re.compile(r"^\+|-+\+|\+-+|--+|\|(?:\s|$)", re.MULTILINE)
# ------------------------------------------------------------
@patch("evennia.server.portal.portal.LoopingCall", new=MagicMock())
class CommandTest(EvenniaTest):
"""
Tests a command
@ -149,11 +150,18 @@ class CommandTest(EvenniaTest):
returned_msg = msg_sep.join(
_RE.sub("", ansi.parse_ansi(mess, strip_ansi=noansi)) for mess in stored_msg
).strip()
if msg == "" and returned_msg or not returned_msg.startswith(msg.strip()):
msg = msg.strip()
if msg == "" and returned_msg or not returned_msg.startswith(msg):
prt = ""
for ic, char in enumerate(msg):
import re
prt += char
sep1 = "\n" + "=" * 30 + "Wanted message" + "=" * 34 + "\n"
sep2 = "\n" + "=" * 30 + "Returned message" + "=" * 32 + "\n"
sep3 = "\n" + "=" * 78
retval = sep1 + msg.strip() + sep2 + returned_msg + sep3
retval = sep1 + msg + sep2 + returned_msg + sep3
raise AssertionError(retval)
else:
returned_msg = "\n".join(str(msg) for msg in stored_msg)
@ -346,11 +354,29 @@ class TestAccount(CommandTest):
self.call(account.CmdOOC(), "", "You go OOC.", caller=self.account)
def test_ic(self):
self.account.db._playable_characters = [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.unpuppet_object(self.session)
self.call(
account.CmdIC(), "Obj", "You become Obj.", caller=self.account, receiver=self.obj1
)
def test_ic__nonaccess(self):
self.account.unpuppet_object(self.session)
self.call(
account.CmdIC(),
"Nonexistent",
"That is not a valid character choice.",
caller=self.account,
receiver=self.account,
)
def test_password(self):
self.call(
account.CmdPassword(),
@ -456,10 +482,18 @@ class TestBuilding(CommandTest):
self.call(building.CmdExamine(), "*TestAccount", "Name/key: TestAccount")
self.char1.db.test = "testval"
self.call(building.CmdExamine(), "self/test", "Persistent attributes:\n test = testval")
self.call(building.CmdExamine(), "self/test", "Persistent attribute(s):\n test = testval")
self.call(building.CmdExamine(), "NotFound", "Could not find 'NotFound'.")
self.call(building.CmdExamine(), "out", "Name/key: out")
# escape inlinefuncs
self.char1.db.test2 = "this is a $random() value."
self.call(
building.CmdExamine(),
"self/test2",
"Persistent attribute(s):\n test2 = this is a \$random() value.",
)
self.room1.scripts.add(self.script.__class__)
self.call(building.CmdExamine(), "")
self.account.scripts.add(self.script.__class__)
@ -505,7 +539,7 @@ class TestBuilding(CommandTest):
self.call(building.CmdSetAttribute(), "Obj2/test2", "Attribute Obj2/test2 = value2")
self.call(building.CmdSetAttribute(), "Obj2/NotFound", "Obj2 has no attribute 'notfound'.")
with mock.patch("evennia.commands.default.building.EvEditor") as mock_ed:
with patch("evennia.commands.default.building.EvEditor") as mock_ed:
self.call(building.CmdSetAttribute(), "/edit Obj2/test3")
mock_ed.assert_called_with(self.char1, Anything, Anything, key="Obj2/test3")
@ -789,7 +823,7 @@ class TestBuilding(CommandTest):
)
self.call(building.CmdDesc(), "", "Usage: ")
with mock.patch("evennia.commands.default.building.EvEditor") as mock_ed:
with patch("evennia.commands.default.building.EvEditor") as mock_ed:
self.call(building.CmdDesc(), "/edit")
mock_ed.assert_called_with(
self.char1,
@ -937,7 +971,11 @@ class TestBuilding(CommandTest):
self.call(building.CmdSetHome(), "Obj = Room2", "Home location of Obj was set to Room")
def test_list_cmdsets(self):
self.call(building.CmdListCmdSets(), "", "<DefaultCharacter (Union, prio 0, perm)>:")
self.call(
building.CmdListCmdSets(),
"",
"<CmdSetHandler> stack:\n <CmdSet DefaultCharacter, Union, perm, prio 0>:",
)
self.call(building.CmdListCmdSets(), "NotFound", "Could not find 'NotFound'")
def test_typeclass(self):
@ -991,6 +1029,34 @@ class TestBuilding(CommandTest):
"All object creation hooks were run. All old attributes where deleted before the swap.",
)
from evennia.prototypes.prototypes import homogenize_prototype
test_prototype = [
homogenize_prototype(
{
"prototype_key": "testkey",
"prototype_tags": [],
"typeclass": "typeclasses.objects.Object",
"key": "replaced_obj",
"attrs": [("foo", "bar", None, ""), ("desc", "protdesc", None, "")],
}
)
]
with patch(
"evennia.commands.default.building.protlib.search_prototype",
new=MagicMock(return_value=test_prototype),
) as mprot:
self.call(
building.CmdTypeclass(),
"/prototype Obj=testkey",
"replaced_obj changed typeclass from "
"evennia.objects.objects.DefaultObject to "
"typeclasses.objects.Object.\nAll object creation hooks were "
"run. Attributes set before swap were not removed. Prototype "
"'replaced_obj' was successfully applied over the object type.",
)
assert self.obj1.db.desc == "protdesc"
def test_lock(self):
self.call(building.CmdLock(), "", "Usage: ")
self.call(building.CmdLock(), "Obj = test:all()", "Added lock 'test:all()' to Obj.")
@ -1038,11 +1104,41 @@ class TestBuilding(CommandTest):
self.call(building.CmdFind(), self.char1.dbref, "Exact dbref match")
self.call(building.CmdFind(), "*TestAccount", "Match")
self.call(building.CmdFind(), "/char Obj")
self.call(building.CmdFind(), "/room Obj")
self.call(building.CmdFind(), "/exit Obj")
self.call(building.CmdFind(), "/char Obj", "No Matches")
self.call(building.CmdFind(), "/room Obj", "No Matches")
self.call(building.CmdFind(), "/exit Obj", "No Matches")
self.call(building.CmdFind(), "/exact Obj", "One Match")
# Test multitype filtering
with patch(
"evennia.commands.default.building.CHAR_TYPECLASS",
"evennia.objects.objects.DefaultCharacter",
):
self.call(building.CmdFind(), "/char/room Obj", "No Matches")
self.call(building.CmdFind(), "/char/room/exit Char", "2 Matches")
self.call(building.CmdFind(), "/char/room/exit/startswith Cha", "2 Matches")
# Test null search
self.call(building.CmdFind(), "=", "Usage: ")
# Test bogus dbref range with no search term
self.call(building.CmdFind(), "= obj", "Invalid dbref range provided (not a number).")
self.call(building.CmdFind(), "= #1a", "Invalid dbref range provided (not a number).")
# Test valid dbref ranges with no search term
id1 = self.obj1.id
id2 = self.obj2.id
maxid = ObjectDB.objects.latest("id").id
maxdiff = maxid - id1 + 1
mdiff = id2 - id1 + 1
self.call(building.CmdFind(), f"=#{id1}", f"{maxdiff} Matches(#{id1}-#{maxid}")
self.call(building.CmdFind(), f"={id1}-{id2}", f"{mdiff} Matches(#{id1}-#{id2}):")
self.call(building.CmdFind(), f"={id1} - {id2}", f"{mdiff} Matches(#{id1}-#{id2}):")
self.call(building.CmdFind(), f"={id1}- #{id2}", f"{mdiff} Matches(#{id1}-#{id2}):")
self.call(building.CmdFind(), f"={id1}-#{id2}", f"{mdiff} Matches(#{id1}-#{id2}):")
self.call(building.CmdFind(), f"=#{id1}-{id2}", f"{mdiff} Matches(#{id1}-#{id2}):")
def test_script(self):
self.call(building.CmdScript(), "Obj = ", "No scripts defined on Obj")
self.call(
@ -1054,7 +1150,7 @@ class TestBuilding(CommandTest):
"= Obj",
"To create a global script you need scripts/add <typeclass>.",
)
self.call(building.CmdScript(), "Obj = ", "dbref obj")
self.call(building.CmdScript(), "Obj ", "dbref ")
self.call(
building.CmdScript(), "/start Obj", "0 scripts started on Obj"
@ -1147,10 +1243,10 @@ class TestBuilding(CommandTest):
)
def test_spawn(self):
def getObject(commandTest, objKeyStr):
def get_object(commandTest, obj_key):
# A helper function to get a spawned object and
# check that it exists in the process.
query = search_object(objKeyStr)
query = search_object(obj_key)
commandTest.assertIsNotNone(query)
commandTest.assertTrue(bool(query))
obj = query[0]
@ -1170,21 +1266,30 @@ class TestBuilding(CommandTest):
inputs=["y"],
)
self.call(
building.CmdSpawn(),
"/save testprot2 = {'key':'Test Char', "
"'typeclass':'evennia.objects.objects.DefaultCharacter'}",
"(Replacing `prototype_key` in prototype with given key.)|Saved prototype: testprot2",
inputs=["y"],
)
self.call(building.CmdSpawn(), "/search ", "Key ")
self.call(building.CmdSpawn(), "/search test;test2", "")
self.call(building.CmdSpawn(), "/search test;test2", "No prototypes found.")
self.call(
building.CmdSpawn(),
"/save {'key':'Test Char', " "'typeclass':'evennia.objects.objects.DefaultCharacter'}",
"To save a prototype it must have the 'prototype_key' set.",
"A prototype_key must be given, either as `prototype_key = <prototype>` or as "
"a key 'prototype_key' inside the prototype structure.",
)
self.call(building.CmdSpawn(), "/list", "Key ")
self.call(building.CmdSpawn(), "testprot", "Spawned Test Char")
# Tests that the spawned object's location is the same as the caharacter's location, since
# Tests that the spawned object's location is the same as the character's location, since
# we did not specify it.
testchar = getObject(self, "Test Char")
testchar = get_object(self, "Test Char")
self.assertEqual(testchar.location, self.char1.location)
testchar.delete()
@ -1201,7 +1306,7 @@ class TestBuilding(CommandTest):
"'key':'goblin', 'location':'%s'}" % spawnLoc.dbref,
"Spawned goblin",
)
goblin = getObject(self, "goblin")
goblin = get_object(self, "goblin")
# Tests that the spawned object's type is a DefaultCharacter.
self.assertIsInstance(goblin, DefaultCharacter)
self.assertEqual(goblin.location, spawnLoc)
@ -1220,7 +1325,7 @@ class TestBuilding(CommandTest):
# Tests "spawn <prototype_name>"
self.call(building.CmdSpawn(), "testball", "Spawned Ball")
ball = getObject(self, "Ball")
ball = get_object(self, "Ball")
self.assertEqual(ball.location, self.char1.location)
self.assertIsInstance(ball, DefaultObject)
ball.delete()
@ -1230,7 +1335,7 @@ class TestBuilding(CommandTest):
self.call(
building.CmdSpawn(), "/n 'BALL'", "Spawned Ball"
) # /n switch is abbreviated form of /noloc
ball = getObject(self, "Ball")
ball = get_object(self, "Ball")
self.assertIsNone(ball.location)
ball.delete()
@ -1249,12 +1354,12 @@ class TestBuilding(CommandTest):
% spawnLoc.dbref,
"Spawned Ball",
)
ball = getObject(self, "Ball")
ball = get_object(self, "Ball")
self.assertEqual(ball.location, spawnLoc)
ball.delete()
# test calling spawn with an invalid prototype.
self.call(building.CmdSpawn(), "'NO_EXIST'", "No prototype named 'NO_EXIST'")
self.call(building.CmdSpawn(), "'NO_EXIST'", "No prototype named 'NO_EXIST' was found.")
# Test listing commands
self.call(building.CmdSpawn(), "/list", "Key ")
@ -1285,13 +1390,12 @@ class TestBuilding(CommandTest):
# spawn/edit with invalid prototype
msg = self.call(
building.CmdSpawn(), "/edit NO_EXISTS", "No prototype 'NO_EXISTS' was found."
building.CmdSpawn(), "/edit NO_EXISTS", "No prototype named 'NO_EXISTS' was found."
)
# spawn/examine (missing prototype)
# lists all prototypes that exist
msg = self.call(building.CmdSpawn(), "/examine")
assert "testball" in msg and "testprot" in msg
self.call(building.CmdSpawn(), "/examine", "You need to specify a prototype-key to show.")
# spawn/examine with valid prototype
# prints the prototype
@ -1300,7 +1404,9 @@ class TestBuilding(CommandTest):
# spawn/examine with invalid prototype
# shows error
self.call(building.CmdSpawn(), "/examine NO_EXISTS", "No prototype 'NO_EXISTS' was found.")
self.call(
building.CmdSpawn(), "/examine NO_EXISTS", "No prototype named 'NO_EXISTS' was found."
)
class TestComms(CommandTest):
@ -1472,11 +1578,11 @@ class TestSystemCommands(CommandTest):
self.call(multimatch, "look", "")
@mock.patch("evennia.commands.default.syscommands.ChannelDB")
@patch("evennia.commands.default.syscommands.ChannelDB")
def test_channelcommand(self, mock_channeldb):
channel = mock.MagicMock()
channel.msg = mock.MagicMock()
mock_channeldb.objects.get_channel = mock.MagicMock(return_value=channel)
channel = MagicMock()
channel.msg = MagicMock()
mock_channeldb.objects.get_channel = MagicMock(return_value=channel)
self.call(syscommands.SystemSendToChannel(), "public:Hello")
channel.msg.assert_called()

View file

@ -294,7 +294,6 @@ You are not yet logged into the game. Commands available at this point:
|wquit|n - abort the connection
First create an account e.g. with |wcreate Anna c67jHL8p|n
(If you have spaces in your name, use double quotes: |wcreate "Anna the Barbarian" c67jHL8p|n
Next you can connect to the game: |wconnect Anna c67jHL8p|n
You can use the |wlook|n command if you want to see the connect screen again.

View file

@ -194,27 +194,71 @@ class TestCmdSetMergers(TestCase):
self.assertEqual(len(cmdset_f.commands), 4)
self.assertTrue(all(True for cmd in cmdset_f.commands if cmd.from_cmdset == "A"))
def test_option_transfer(self):
"Test transfer of cmdset options"
class TestOptionTransferTrue(TestCase):
"""
Test cmdset-merge transfer of the cmdset-special options
(no_exits/channels/objs/duplicates etc)
cmdset A has all True options
"""
def setUp(self):
super().setUp()
self.cmdset_a = _CmdSetA()
self.cmdset_b = _CmdSetB()
self.cmdset_c = _CmdSetC()
self.cmdset_d = _CmdSetD()
self.cmdset_a.priority = 0
self.cmdset_b.priority = 0
self.cmdset_c.priority = 0
self.cmdset_d.priority = 0
self.cmdset_a.no_exits = True
self.cmdset_a.no_objs = True
self.cmdset_a.no_channels = True
self.cmdset_a.duplicates = True
def test_option_transfer__reverse_sameprio_passthrough(self):
"""
A has all True options, merges last (normal reverse merge), same prio.
The options should pass through to F since none of the other cmdsets
care to change the setting from their default None.
Since A.duplicates = True, the final result is an union of duplicate
pairs (8 commands total).
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
# the options should pass through since none of the other cmdsets care
# to change the setting from None.
a.no_exits = True
a.no_objs = True
a.no_channels = True
a.duplicates = True
cmdset_f = d + c + b + a # reverse, same-prio
self.assertTrue(cmdset_f.no_exits)
self.assertTrue(cmdset_f.no_objs)
self.assertTrue(cmdset_f.no_channels)
self.assertTrue(cmdset_f.duplicates)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 8)
def test_option_transfer__forward_sameprio_passthrough(self):
"""
A has all True options, merges first (forward merge), same prio. This
should pass those options through since the other all have options set
to None. The exception is `duplicates` since that is determined by
the two last mergers in the chain both being True.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
cmdset_f = a + b + c + d # forward, same-prio
self.assertTrue(cmdset_f.no_exits)
self.assertTrue(cmdset_f.no_objs)
self.assertTrue(cmdset_f.no_channels)
self.assertFalse(cmdset_f.duplicates)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__reverse_highprio_passthrough(self):
"""
A has all True options, merges last (normal reverse merge) with the
highest prio. This should also pass through.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = 2
b.priority = 1
c.priority = 0
@ -223,14 +267,35 @@ class TestCmdSetMergers(TestCase):
self.assertTrue(cmdset_f.no_exits)
self.assertTrue(cmdset_f.no_objs)
self.assertTrue(cmdset_f.no_channels)
self.assertTrue(cmdset_f.duplicates)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__forward_highprio_passthrough(self):
"""
A has all True options, merges first (forward merge). This is a bit
synthetic since it will never happen in practice, but logic should
still make it pass through.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = 2
b.priority = 1
c.priority = 0
d.priority = -1
cmdset_f = a + b + c + d # forward, A top priority. This never happens in practice.
self.assertTrue(cmdset_f.no_exits)
self.assertTrue(cmdset_f.no_objs)
self.assertTrue(cmdset_f.no_channels)
self.assertTrue(cmdset_f.duplicates)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__reverse_lowprio_passthrough(self):
"""
A has all True options, merges last (normal reverse merge) with the lowest
prio. This never happens (it would always merge first) but logic should hold
and pass through since the other cmdsets have None.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = -1
b.priority = 0
c.priority = 1
@ -239,32 +304,679 @@ class TestCmdSetMergers(TestCase):
self.assertTrue(cmdset_f.no_exits)
self.assertTrue(cmdset_f.no_objs)
self.assertTrue(cmdset_f.no_channels)
self.assertFalse(cmdset_f.duplicates)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__forward_lowprio_passthrough(self):
"""
A has all True options, merges first (forward merge) with lowest prio. This
is the normal behavior for a low-prio cmdset. Passthrough should happen.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = -1
b.priority = 0
c.priority = 1
d.priority = 2
cmdset_f = a + b + c + d # forward, A low prio
self.assertTrue(cmdset_f.no_exits)
self.assertTrue(cmdset_f.no_objs)
self.assertTrue(cmdset_f.no_channels)
self.assertFalse(cmdset_f.duplicates)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__reverse_highprio_block_passthrough(self):
"""
A has all True options, other cmdsets has False. A merges last with high
prio. A should retain its option values and override the others
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = 2
b.priority = 1
c.priority = 0
d.priority = -1
c.no_exits = False
b.no_objs = False
d.duplicates = False
# higher-prio sets will change the option up the chain
cmdset_f = d + c + b + a # reverse, high prio
self.assertTrue(cmdset_f.no_exits)
self.assertTrue(cmdset_f.no_objs)
self.assertTrue(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__forward_highprio_block_passthrough(self):
"""
A has all True options, other cmdsets has False. A merges last with high
prio. This situation should never happen, but logic should hold - the highest
prio's options should survive the merge process.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = 2
b.priority = 1
c.priority = 0
d.priority = -1
c.no_exits = False
b.no_channels = False
b.no_objs = False
d.duplicates = False
# higher-prio sets will change the option up the chain
cmdset_f = a + b + c + d # forward, high prio, never happens
self.assertTrue(cmdset_f.no_exits)
self.assertTrue(cmdset_f.no_objs)
self.assertTrue(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__forward_lowprio_block(self):
"""
A has all True options, other cmdsets has False. A merges last with low
prio. This should result in its values being blocked and come out False.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = -1
b.priority = 0
c.priority = 1
d.priority = 2
c.no_exits = False
c.no_channels = False
b.no_objs = False
d.duplicates = False
# higher-prio sets will change the option up the chain
cmdset_f = a + b + c + d # forward, A low prio
self.assertFalse(cmdset_f.no_exits)
self.assertFalse(cmdset_f.no_objs)
self.assertFalse(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__forward_lowprio_block_partial(self):
"""
A has all True options, other cmdsets has False excet C which has a None
for `no_channels`. A merges last with low
prio. This should result in its values being blocked and come out False
except for no_channels which passes through.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = -1
b.priority = 0
c.priority = 1
d.priority = 2
c.no_exits = False
c.no_channels = None # passthrough
b.no_objs = False
d.duplicates = False
# higher-prio sets will change the option up the chain
cmdset_f = a + b + c + d # forward, A low prio
self.assertFalse(cmdset_f.no_exits)
self.assertFalse(cmdset_f.no_objs)
self.assertTrue(cmdset_f.no_channels)
self.assertFalse(cmdset_f.duplicates)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
a.priority = 0
b.priority = 0
def test_option_transfer__reverse_highprio_sameprio_order_last(self):
"""
A has all True options and highest prio, D has False and lowest prio,
others are passthrough. B has the same prio as A, with passthrough.
Since A is merged last, this should give prio to A's options
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = 2
b.priority = 2
c.priority = 0
d.priority = 0
d.priority = -1
d.no_channels = False
d.no_exits = False
d.no_objs = None
d.duplicates = False
# higher-prio sets will change the option up the chain
cmdset_f = d + c + b + a # reverse, A same prio, merged after b
self.assertTrue(cmdset_f.no_exits)
self.assertTrue(cmdset_f.no_objs)
self.assertTrue(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 8)
def test_option_transfer__reverse_highprio_sameprio_order_first(self):
"""
A has all True options and highest prio, D has False and lowest prio,
others are passthrough. B has the same prio as A, with passthrough.
While B, with None-values, is merged after A, A's options should have
replaced those of D at that point, and since B has passthrough the
final result should contain A's True options.
Note that despite A having duplicates=True, there is no duplication in
the DB + A merger since they have different priorities.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = 2
b.priority = 2
c.priority = 0
d.priority = -1
d.no_channels = False
d.no_exits = False
d.no_objs = False
d.duplicates = False
# higher-prio sets will change the option up the chain
cmdset_f = d + c + a + b # reverse, A same prio, merged before b
self.assertTrue(cmdset_f.no_exits)
self.assertTrue(cmdset_f.no_objs)
self.assertTrue(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__reverse_lowprio_block(self):
"""
A has all True options, other cmdsets has False. A merges last with low
prio. This usually doesn't happen- it should merge last. But logic should
hold and the low-prio cmdset's values should be blocked and come out False.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = -1
b.priority = 0
c.priority = 1
d.priority = 2
c.no_exits = False
d.no_channels = False
b.no_objs = False
d.duplicates = False
# higher-prio sets will change the option up the chain
cmdset_f = d + c + b + a # reverse, A low prio, never happens
self.assertFalse(cmdset_f.no_exits)
self.assertFalse(cmdset_f.no_objs)
self.assertFalse(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
class TestOptionTransferFalse(TestCase):
"""
Test cmdset-merge transfer of the cmdset-special options
(no_exits/channels/objs/duplicates etc)
cmdset A has all False options
"""
def setUp(self):
super().setUp()
self.cmdset_a = _CmdSetA()
self.cmdset_b = _CmdSetB()
self.cmdset_c = _CmdSetC()
self.cmdset_d = _CmdSetD()
self.cmdset_a.priority = 0
self.cmdset_b.priority = 0
self.cmdset_c.priority = 0
self.cmdset_d.priority = 0
self.cmdset_a.no_exits = False
self.cmdset_a.no_objs = False
self.cmdset_a.no_channels = False
self.cmdset_a.duplicates = False
def test_option_transfer__reverse_sameprio_passthrough(self):
"""
A has all False options, merges last (normal reverse merge), same prio.
The options should pass through to F since none of the other cmdsets
care to change the setting from their default None.
Since A has duplicates=False, the result is a unique union of 4 cmds.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
cmdset_f = d + c + b + a # reverse, same-prio
self.assertFalse(cmdset_f.no_exits)
self.assertFalse(cmdset_f.no_objs)
self.assertFalse(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__forward_sameprio_passthrough(self):
"""
A has all False options, merges first (forward merge), same prio. This
should pass those options through since the other all have options set
to None. The exception is `duplicates` since that is determined by
the two last mergers in the chain both being .
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
cmdset_f = a + b + c + d # forward, same-prio
self.assertFalse(cmdset_f.no_exits)
self.assertFalse(cmdset_f.no_objs)
self.assertFalse(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__reverse_highprio_passthrough(self):
"""
A has all False options, merges last (normal reverse merge) with the
highest prio. This should also pass through.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = 2
b.priority = 1
c.priority = 0
d.priority = -1
cmdset_f = d + c + b + a # reverse, A top priority
self.assertFalse(cmdset_f.no_exits)
self.assertFalse(cmdset_f.no_objs)
self.assertFalse(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__forward_highprio_passthrough(self):
"""
A has all False options, merges first (forward merge). This is a bit
synthetic since it will never happen in practice, but logic should
still make it pass through.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = 2
b.priority = 1
c.priority = 0
d.priority = -1
cmdset_f = a + b + c + d # forward, A top priority. This never happens in practice.
self.assertFalse(cmdset_f.no_exits)
self.assertFalse(cmdset_f.no_objs)
self.assertFalse(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__reverse_lowprio_passthrough(self):
"""
A has all False options, merges last (normal reverse merge) with the lowest
prio. This never happens (it would always merge first) but logic should hold
and pass through since the other cmdsets have None.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = -1
b.priority = 0
c.priority = 1
d.priority = 2
cmdset_f = d + c + b + a # reverse, A low prio. This never happens in practice.
self.assertFalse(cmdset_f.no_exits)
self.assertFalse(cmdset_f.no_objs)
self.assertFalse(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__forward_lowprio_passthrough(self):
"""
A has all False options, merges first (forward merge) with lowest prio. This
is the normal behavior for a low-prio cmdset. Passthrough should happen.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = -1
b.priority = 0
c.priority = 1
d.priority = 2
cmdset_f = a + b + c + d # forward, A low prio
self.assertFalse(cmdset_f.no_exits)
self.assertFalse(cmdset_f.no_objs)
self.assertFalse(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__reverse_highprio_block_passthrough(self):
"""
A has all False options, other cmdsets has True. A merges last with high
prio. A should retain its option values and override the others
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = 2
b.priority = 1
c.priority = 0
d.priority = -1
c.no_exits = True
b.no_objs = True
d.duplicates = True
# higher-prio sets will change the option up the chain
cmdset_f = d + c + b + a # reverse, high prio
self.assertFalse(cmdset_f.no_exits)
self.assertFalse(cmdset_f.no_objs)
self.assertFalse(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__forward_highprio_block_passthrough(self):
"""
A has all False options, other cmdsets has True. A merges last with high
prio. This situation should never happen, but logic should hold - the highest
prio's options should survive the merge process.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = 2
b.priority = 1
c.priority = 0
d.priority = -1
c.no_exits = True
b.no_channels = True
b.no_objs = True
d.duplicates = True
# higher-prio sets will change the option up the chain
cmdset_f = a + b + c + d # forward, high prio, never happens
self.assertFalse(cmdset_f.no_exits)
self.assertFalse(cmdset_f.no_objs)
self.assertFalse(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__forward_lowprio_block(self):
"""
A has all False options, other cmdsets has True. A merges last with low
prio. This should result in its values being blocked and come out False.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = -1
b.priority = 0
c.priority = 1
d.priority = 2
c.no_exits = True
c.no_channels = True
b.no_objs = True
d.duplicates = True
# higher-prio sets will change the option up the chain
cmdset_f = a + b + c + d # forward, A low prio
self.assertTrue(cmdset_f.no_exits)
self.assertTrue(cmdset_f.no_objs)
self.assertTrue(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__forward_lowprio_block_partial(self):
"""
A has all False options, other cmdsets has True excet C which has a None
for `no_channels`. A merges last with low
prio. This should result in its values being blocked and come out True
except for no_channels which passes through.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = -1
b.priority = 0
c.priority = 1
d.priority = 2
c.no_exits = True
c.no_channels = None # passthrough
b.no_objs = True
d.duplicates = True
# higher-prio sets will change the option up the chain
cmdset_f = a + b + c + d # forward, A low prio
self.assertTrue(cmdset_f.no_exits)
self.assertTrue(cmdset_f.no_objs)
self.assertFalse(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__reverse_sameprio_order_last(self):
"""
A has all False options and highest prio, D has True and lowest prio,
others are passthrough. B has the same prio as A, with passthrough.
Since A is merged last, this should give prio to A's False options
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = 2
b.priority = 2
c.priority = 0
d.priority = -1
d.no_channels = True
d.no_exits = True
d.no_objs = True
d.duplicates = False
# higher-prio sets will change the option up the chain
cmdset_f = d + c + b + a # reverse, A high prio, merged after b
self.assertFalse(cmdset_f.no_exits)
self.assertFalse(cmdset_f.no_objs)
self.assertFalse(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__reverse_sameprio_order_first(self):
"""
A has all False options and highest prio, D has True and lowest prio,
others are passthrough. B has the same prio as A, with passthrough.
While B, with None-values, is merged after A, A's options should have
replaced those of D at that point, and since B has passthrough the
final result should contain A's False options.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = 2
b.priority = 2
c.priority = 0
d.priority = -1
d.no_channels = True
d.no_exits = True
d.no_objs = True
d.duplicates = False
# higher-prio sets will change the option up the chain
cmdset_f = d + c + a + b # reverse, A high prio, merged before b
self.assertFalse(cmdset_f.no_exits)
self.assertFalse(cmdset_f.no_objs)
self.assertFalse(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
def test_option_transfer__reverse_lowprio_block(self):
"""
A has all False options, other cmdsets has True. A merges last with low
prio. This usually doesn't happen- it should merge last. But logic should
hold and the low-prio cmdset's values should be blocked and come out True.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = -1
b.priority = 0
c.priority = 1
d.priority = 2
c.no_exits = True
d.no_channels = True
b.no_objs = True
d.duplicates = True
# higher-prio sets will change the option up the chain
cmdset_f = d + c + b + a # reverse, A low prio, never happens
self.assertTrue(cmdset_f.no_exits)
self.assertTrue(cmdset_f.no_objs)
self.assertTrue(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
class TestDuplicateBehavior(TestCase):
"""
Test behavior of .duplicate option, which is a bit special in that it
doesn't propagate.
`A.duplicates=True` for all tests.
"""
def setUp(self):
super().setUp()
self.cmdset_a = _CmdSetA()
self.cmdset_b = _CmdSetB()
self.cmdset_c = _CmdSetC()
self.cmdset_d = _CmdSetD()
self.cmdset_a.priority = 0
self.cmdset_b.priority = 0
self.cmdset_c.priority = 0
self.cmdset_d.priority = 0
self.cmdset_a.duplicates = True
def test_reverse_sameprio_duplicate(self):
"""
Test of `duplicates` transfer which does not propagate. Only
A has duplicates=True.
D + B = DB (no duplication, DB.duplication=None)
DB + C = DBC (no duplication, DBC.duplication=None)
DBC + A = final (duplication, final.duplication=None)
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
cmdset_f = d + b + c + a # two last mergers duplicates=True
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 8)
def test_reverse_sameprio_duplicate(self):
"""
Test of `duplicates` transfer, which does not propagate.
C.duplication=True
D + B = DB (no duplication, DB.duplication=None)
DB + C = DBC (duplication, DBC.duplication=None)
DBC + A = final (duplication, final.duplication=None)
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
c.duplicates = True
cmdset_f = d + b + c + a # two last mergers duplicates=True
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 10)
def test_forward_sameprio_duplicate(self):
"""
Test of `duplicates` transfer which does not propagate.
C.duplication=True, merges later than A
D + B = DB (no duplication, DB.duplication=None)
DB + A = DBA (duplication, DBA.duplication=None)
DBA + C = final (duplication, final.duplication=None)
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
c.duplicates = True
cmdset_f = d + b + a + c # two last mergers duplicates=True
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 10)
def test_reverse_sameprio_duplicate_reverse(self):
"""
Test of `duplicates` transfer which does not propagate.
C.duplication=False (explicit), merges before A. This behavior is the
same as if C.duplication=None, since A merges later and takes
precedence.
D + B = DB (no duplication, DB.duplication=None)
DB + C = DBC (no duplication, DBC.duplication=None)
DBC + A = final (duplication, final.duplication=None)
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
c.duplicates = False
cmdset_f = d + b + c + a # a merges last, takes precedence
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 8)
def test_reverse_sameprio_duplicate_forward(self):
"""
Test of `duplicates` transfer which does not propagate.
C.duplication=False (explicit), merges after A. This just means
only A causes duplicates, earlier in the chain.
D + B = DB (no duplication, DB.duplication=None)
DB + A = DBA (duplication, DBA.duplication=None)
DBA + C = final (no duplication, final.duplication=None)
Note that DBA has 8 cmds due to A merging onto DB with duplication,
but since C merges onto this with no duplication, the union will hold
6 commands, since C has two commands that replaces the 4 duplicates
with uniques copies from C.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
c.duplicates = False
cmdset_f = d + b + a + c # a merges before c
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 6)
class TestOptionTransferReplace(TestCase):
"""
Test option transfer through more complex merge types.
"""
def setUp(self):
super().setUp()
self.cmdset_a = _CmdSetA()
self.cmdset_b = _CmdSetB()
self.cmdset_c = _CmdSetC()
self.cmdset_d = _CmdSetD()
self.cmdset_a.priority = 0
self.cmdset_b.priority = 0
self.cmdset_c.priority = 0
self.cmdset_d.priority = 0
self.cmdset_a.no_exits = True
self.cmdset_a.no_objs = True
self.cmdset_a.no_channels = True
self.cmdset_a.duplicates = True
def test_option_transfer__replace_reverse_highprio(self):
"""
A has all options True and highest priority. C has them False and is
Replace-type.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.priority = 2
b.priority = 2
c.priority = 0
c.mergetype = "Replace"
c.no_channels = False
c.no_exits = False
c.no_objs = False
c.duplicates = False
d.priority = -1
cmdset_f = d + c + b + a # reverse, A high prio, C Replace
self.assertTrue(cmdset_f.no_exits)
self.assertTrue(cmdset_f.no_objs)
self.assertTrue(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 7)
def test_option_transfer__replace_reverse_highprio_from_false(self):
"""
Inverse of previous test: A has all options False and highest priority.
C has them True and is Replace-type.
"""
a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d
a.no_exits = False
a.no_objs = False
a.no_channels = False
a.duplicates = False
a.priority = 2
b.priority = 2
c.priority = 0
c.mergetype = "Replace"
c.no_channels = True
c.no_exits = True
c.no_objs = True
c.duplicates = True
d.priority = -1
cmdset_f = d + c + b + a # reverse, A high prio, C Replace
self.assertFalse(cmdset_f.no_exits)
self.assertFalse(cmdset_f.no_objs)
self.assertFalse(cmdset_f.no_channels)
self.assertIsNone(cmdset_f.duplicates)
self.assertEqual(len(cmdset_f.commands), 4)
# test cmdhandler functions
@ -487,3 +1199,28 @@ class TestCmdParser(TestCase):
cmdparser.cmdparser("test1hello", a_cmdset, None),
[("test1", "hello", bcmd, 5, 0.5, "test1")],
)
class TestCmdSetNesting(EvenniaTest):
"""
Test 'nesting' of cmdsets by adding
"""
def test_nest(self):
class CmdA(Command):
key = "a"
def func(self):
self.msg(str(self.obj))
class CmdSetA(CmdSet):
def at_cmdset_creation(self):
self.add(CmdA)
class CmdSetB(CmdSet):
def at_cmdset_creation(self):
self.add(CmdSetA)
cmd = self.char1.cmdset.cmdset_stack[-1].commands[0]
self.assertEqual(cmd.obj, self.char1)

View file

@ -24,16 +24,20 @@ does this for you.
"""
from django.conf import settings
from evennia.commands import cmdset, command
from evennia.commands import cmdset
from evennia.utils.logger import tail_log_file
from evennia.utils.utils import class_from_module
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
# we must late-import these since any overloads are likely to
# themselves be using these classes leading to a circular import.
_CHANNEL_HANDLER_CLASS = None
_CHANNEL_COMMAND_CLASS = None
_CHANNELDB = None
_COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
class ChannelCommand(command.Command):
class ChannelCommand(_COMMAND_DEFAULT_CLASS):
"""
{channelkey} channel
@ -115,17 +119,17 @@ class ChannelCommand(command.Command):
caller = caller if not hasattr(caller, "account") else caller.account
unmuted = channel.unmute(caller)
if unmuted:
self.msg("You start listening to %s." % channel)
self.msg(_("You start listening to %s.") % channel)
return
self.msg("You were already listening to %s." % channel)
self.msg(_("You were already listening to %s.") % channel)
return
if msg == "off":
caller = caller if not hasattr(caller, "account") else caller.account
muted = channel.mute(caller)
if muted:
self.msg("You stop listening to %s." % channel)
self.msg(_("You stop listening to %s.") % channel)
return
self.msg("You were already not listening to %s." % channel)
self.msg(_("You were already not listening to %s.") % channel)
return
if self.history_start is not None:
# Try to view history
@ -140,7 +144,7 @@ class ChannelCommand(command.Command):
else:
caller = caller if not hasattr(caller, "account") else caller.account
if caller in channel.mutelist:
self.msg("You currently have %s muted." % channel)
self.msg(_("You currently have %s muted.") % channel)
return
channel.msg(msg, senders=self.caller, online=True)
@ -314,5 +318,6 @@ class ChannelHandler(object):
return chan_cmdset
CHANNEL_HANDLER = ChannelHandler()
# set up the singleton
CHANNEL_HANDLER = class_from_module(settings.CHANNEL_HANDLER_CLASS)()
CHANNELHANDLER = CHANNEL_HANDLER # legacy

View file

@ -51,6 +51,8 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase):
self.attributes.add("keep_log", cdict["keep_log"])
if cdict.get("desc"):
self.attributes.add("desc", cdict["desc"])
if cdict.get("tags"):
self.tags.batch_add(*cdict["tags"])
def basetype_setup(self):
# delayed import of the channelhandler
@ -253,7 +255,7 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase):
key (str): This must be unique.
account (Account): Account to attribute this object to.
Kwargs:
Keyword Args:
aliases (list of str): List of alternative (likely shorter) keynames.
description (str): A description of the channel, for use in listings.
locks (str): Lockstring.
@ -394,7 +396,8 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase):
to build senders for the message.
sender_strings (list, optional): Name strings of senders. Used for external
connections where the sender is not an account or object.
When this is defined, external will be assumed.
When this is defined, external will be assumed. The list will be
filtered so each sender-string only occurs once.
keep_log (bool or None, optional): This allows to temporarily change the logging status of
this channel message. If `None`, the Channel's `keep_log` Attribute will
be used. If `True` or `False`, that logging status will be used for this
@ -425,6 +428,8 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase):
msgobj = self.pre_send_message(msgobj)
if not msgobj:
return False
if sender_strings:
sender_strings = list(set(make_iter(sender_strings)))
msgobj = self.message_transform(
msgobj, emit=emit, sender_strings=sender_strings, external=external
)

View file

@ -8,6 +8,7 @@ Comm system components.
from django.db.models import Q
from evennia.typeclasses.managers import TypedObjectManager, TypeclassManager
from evennia.utils import logger
from evennia.utils.utils import dbref
_GA = object.__getattribute__
_AccountDB = None
@ -31,32 +32,6 @@ class CommError(Exception):
#
def dbref(inp, reqhash=True):
"""
Valid forms of dbref (database reference number) are either a
string '#N' or an integer N.
Args:
inp (int or str): A possible dbref to check syntactically.
reqhash (bool): Require an initial hash `#` to accept.
Returns:
is_dbref (int or None): The dbref integer part if a valid
dbref, otherwise `None`.
"""
if reqhash and not (isinstance(inp, str) and inp.startswith("#")):
return None
if isinstance(inp, str):
inp = inp.lstrip("#")
try:
if int(inp) < 0:
return None
except Exception:
return None
return inp
def identify_object(inp):
"""
Helper function. Identifies if an object is an account or an object;

View file

@ -400,9 +400,11 @@ class Msg(SharedMemoryModel):
def __str__(self):
"This handles what is shown when e.g. printing the message"
senders = ",".join(obj.key for obj in self.senders)
senders = ",".join(getattr(obj, "key", str(obj)) for obj in self.senders)
receivers = ",".join(
["[%s]" % obj.key for obj in self.channels] + [obj.key for obj in self.receivers]
["[%s]" % getattr(obj, "key", str(obj)) for obj in self.channels]
+ [getattr(obj, "key", str(obj)) for obj in self.receivers]
)
return "%s->%s: %s" % (senders, receivers, crop(self.message, width=40))

View file

@ -1,5 +1,6 @@
from evennia.utils.test_resources import EvenniaTest
from evennia import DefaultChannel
from evennia.utils.create import create_message
from evennia.utils.test_resources import EvenniaTest
class ObjectCreationTest(EvenniaTest):
@ -10,3 +11,43 @@ class ObjectCreationTest(EvenniaTest):
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc)
def test_message_create(self):
msg = create_message("peewee herman", "heh-heh!", header="mail time!")
self.assertTrue(msg)
self.assertEqual(str(msg), "peewee herman->: heh-heh!")
class ChannelWholistTests(EvenniaTest):
def setUp(self):
super().setUp()
self.default_channel, _ = DefaultChannel.create(
"coffeetalk", description="A place to talk about coffee."
)
self.default_channel.connect(self.obj1)
def test_wholist_shows_subscribed_objects(self):
expected = "Obj"
result = self.default_channel.wholist
self.assertEqual(expected, result)
def test_wholist_shows_none_when_empty(self):
# No one hates dogs
empty_channel, _ = DefaultChannel.create(
"doghaters", description="A place where dog haters unite."
)
expected = "<None>"
result = empty_channel.wholist
self.assertEqual(expected, result)
def test_wholist_does_not_show_muted_objects(self):
self.default_channel.mute(self.obj2)
expected = "Obj"
result = self.default_channel.wholist
self.assertEqual(expected, result)
def test_wholist_shows_connected_object_as_bold(self):
self.default_channel.connect(self.char1)
expected = "Obj, |wChar|n"
result = self.default_channel.wholist
self.assertEqual(expected, result)

View file

@ -175,7 +175,7 @@ def _call_or_get(value, menu=None, choice=None, string=None, obj=None, caller=No
Args:
value (any): the value to obtain. It might be a callable (see note).
Kwargs:
Keyword Args:
menu (BuildingMenu, optional): the building menu to pass to value
if it is a callable.
choice (Choice, optional): the choice to pass to value if a callable.

View file

@ -160,7 +160,7 @@ def get_worn_clothes(character, exclude_covered=False):
Args:
character (obj): The character to get a list of worn clothes from.
Kwargs:
Keyword Args:
exclude_covered (bool): If True, excludes clothes covered by other
clothing from the returned list.
@ -237,7 +237,7 @@ class Clothing(DefaultObject):
wearer (obj): character object wearing this clothing object
wearstyle (True or str): string describing the style of wear or True for none
Kwargs:
Keyword Args:
quiet (bool): If false, does not message the room
Notes:
@ -276,7 +276,7 @@ class Clothing(DefaultObject):
Args:
wearer (obj): character object wearing this clothing object
Kwargs:
Keyword Args:
quiet (bool): If false, does not message the room
"""
self.db.worn = False

View file

@ -125,14 +125,14 @@ CURLY_COLOR_ANSI_EXTRA_MAP = [
(r"{c", _ANSI_HILITE + _ANSI_CYAN),
(r"{w", _ANSI_HILITE + _ANSI_WHITE), # pure white
(r"{x", _ANSI_HILITE + _ANSI_BLACK), # dark grey
(r"{R", _ANSI_HILITE + _ANSI_RED),
(r"{G", _ANSI_HILITE + _ANSI_GREEN),
(r"{Y", _ANSI_HILITE + _ANSI_YELLOW),
(r"{B", _ANSI_HILITE + _ANSI_BLUE),
(r"{M", _ANSI_HILITE + _ANSI_MAGENTA),
(r"{C", _ANSI_HILITE + _ANSI_CYAN),
(r"{W", _ANSI_HILITE + _ANSI_WHITE), # light grey
(r"{X", _ANSI_HILITE + _ANSI_BLACK), # pure black
(r"{R", _ANSI_UNHILITE + _ANSI_RED),
(r"{G", _ANSI_UNHILITE + _ANSI_GREEN),
(r"{Y", _ANSI_UNHILITE + _ANSI_YELLOW),
(r"{B", _ANSI_UNHILITE + _ANSI_BLUE),
(r"{M", _ANSI_UNHILITE + _ANSI_MAGENTA),
(r"{C", _ANSI_UNHILITE + _ANSI_CYAN),
(r"{W", _ANSI_UNHILITE + _ANSI_WHITE), # light grey
(r"{X", _ANSI_UNHILITE + _ANSI_BLACK), # pure black
# hilight-able colors
(r"{h", _ANSI_HILITE),
(r"{H", _ANSI_UNHILITE),

View file

@ -95,7 +95,7 @@ def gametime_to_realtime(format=False, **kwargs):
in-game, you will be able to find the number of real-world seconds this
corresponds to (hint: Interval events deal with real life seconds).
Kwargs:
Keyword Args:
format (bool): Formatting the output.
days, month etc (int): These are the names of time units that must
match the `settings.TIME_UNITS` dict keys.
@ -131,7 +131,7 @@ def realtime_to_gametime(secs=0, mins=0, hrs=0, days=0, weeks=0, months=0, yrs=0
interval would correspond to. This is usually a lot less
interesting than the other way around.
Kwargs:
Keyword Args:
times (int): The various components of the time.
format (bool): Formatting the output.
@ -298,6 +298,9 @@ class GametimeScript(DefaultScript):
def at_repeat(self):
"""Call the callback and reset interval."""
from evennia.utils.utils import calledby
callback = self.db.callback
if callback:
callback()

View file

@ -219,7 +219,7 @@ class BaseState(object):
"""
This is a convenience-wrapper for quickly building EvscapeRoom objects.
Kwargs:
Keyword Args:
typeclass (str): This can take just the class-name in the evscaperoom's
objects.py module. Otherwise, a full path or the actual class
is needed (for custom state objects, just give the class directly).

View file

@ -27,7 +27,7 @@ def create_evscaperoom_object(
Note that for the purpose of the Evscaperoom, we only allow one instance
of each *name*, deleting the old version if it already exists.
Kwargs:
Keyword Args:
typeclass (str): This can take just the class-name in the evscaperoom's
objects.py module. Otherwise, a full path is needed.
key (str): Name of object.
@ -69,7 +69,7 @@ def create_fantasy_word(length=5, capitalize=True):
"""
Create a random semi-pronouncable 'word'.
Kwargs:
Keyword Args:
length (int): The desired length of the 'word'.
capitalize (bool): If the return should be capitalized or not
Returns:

View file

@ -8,26 +8,39 @@ insert custom markers in their text to indicate gender-aware
messaging. It relies on a modified msg() and is meant as an
inspiration and starting point to how to do stuff like this.
When in use, all messages being sent to the character will make use of
the character's gender, for example the echo
An object can have the following genders:
- male (he/his)
- female (her/hers)
- neutral (it/its)
- ambiguous (they/them/their/theirs)
When in use, messages can contain special tags to indicate pronouns gendered
based on the one being addressed. Capitalization will be retained.
- `|s`, `|S`: Subjective form: he, she, it, He, She, It, They
- `|o`, `|O`: Objective form: him, her, it, Him, Her, It, Them
- `|p`, `|P`: Possessive form: his, her, its, His, Her, Its, Their
- `|a`, `|A`: Absolute Possessive form: his, hers, its, His, Hers, Its, Theirs
For example,
```
char.msg("%s falls on |p face with a thud." % char.key)
"Tom falls on his face with a thud"
```
will result in "Tom falls on his|her|its|their face with a thud"
depending on the gender of the object being messaged. Default gender
is "ambiguous" (they).
The default gender is "ambiguous" (they/them/their/theirs).
To use, have DefaultCharacter inherit from this, or change
setting.DEFAULT_CHARACTER to point to this class.
The `@gender` command needs to be added to the default cmdset before
it becomes available.
The `@gender` command is used to set the gender. It needs to be added to the
default cmdset before it becomes available.
"""
import re
from evennia.utils import logger
from evennia import DefaultCharacter
from evennia import Command
@ -107,14 +120,17 @@ class GenderCharacter(DefaultCharacter):
pronoun = _GENDER_PRONOUN_MAP[gender][typ.lower()]
return pronoun.capitalize() if typ.isupper() else pronoun
def msg(self, text, from_obj=None, session=None, **kwargs):
def msg(self, text=None, from_obj=None, session=None, **kwargs):
"""
Emits something to a session attached to the object.
Overloads the default msg() implementation to include
gender-aware markers in output.
Args:
text (str, optional): The message to send
text (str or tuple, optional): The message to send. This
is treated internally like any send-command, so its
value can be a tuple if sending multiple arguments to
the `text` oob command.
from_obj (obj, optional): object that is sending. If
given, at_msg_send will be called
session (Session or list, optional): session or list of
@ -125,9 +141,17 @@ class GenderCharacter(DefaultCharacter):
All extra kwargs will be passed on to the protocol.
"""
# pre-process the text before continuing
if text is None:
super().msg(from_obj=from_obj, session=session, **kwargs)
return
try:
text = _RE_GENDER_PRONOUN.sub(self._get_pronoun, text)
if text and isinstance(text, tuple):
text = (_RE_GENDER_PRONOUN.sub(self._get_pronoun, text[0]), *text[1:])
else:
text = _RE_GENDER_PRONOUN.sub(self._get_pronoun, text)
except TypeError:
pass
except Exception as e:
logger.log_trace(e)
super().msg(text, from_obj=from_obj, session=session, **kwargs)

View file

@ -155,7 +155,7 @@ class CallbackHandler(object):
callback_name (str): the callback name to call.
*args: additional variables for this callback.
Kwargs:
Keyword Args:
number (int, optional): call just a specific callback.
parameters (str, optional): call a callback with parameters.
locals (dict, optional): a locals replacement.

View file

@ -28,7 +28,7 @@ def get(**kwargs):
"""
Return an object with the given search option or None if None is found.
Kwargs:
Keyword Args:
Any searchable data or property (id, db_key, db_location...).
Returns:

View file

@ -397,7 +397,7 @@ class EventHandler(DefaultScript):
callback_name (str): the callback name to call.
*args: additional variables for this callback.
Kwargs:
Keyword Args:
number (int, optional): call just a specific callback.
parameters (str, optional): call a callback with parameters.
locals (dict, optional): a locals replacement.

View file

@ -421,7 +421,7 @@ class EventCharacter(DefaultCharacter):
Args:
message (str): The suggested say/whisper text spoken by self.
Kwargs:
Keyword Args:
whisper (bool): If True, this is a whisper rather than
a say. This is sent by the whisper command by default.
Other verbal commands could use this hook in similar
@ -477,7 +477,7 @@ class EventCharacter(DefaultCharacter):
(by default only used by whispers).
msg_receiver(str, optional): Specific message for receiver only.
mapping (dict, optional): Additional mapping in messages.
Kwargs:
Keyword Args:
whisper (bool): If this is a whisper rather than a say. Kwargs
can be used by other verbal commands in a similar way.

View file

@ -238,7 +238,7 @@ class CmdMail(default_cmds.MuxAccountCommand):
else:
raise IndexError
except IndexError:
self.caller.msg("Message does not exixt.")
self.caller.msg("Message does not exist.")
except ValueError:
self.caller.msg("Usage: @mail/forward <account list>=<#>[/<Message>]")
elif "reply" in self.switches or "rep" in self.switches:

View file

@ -289,7 +289,7 @@ class CmdCreatePuzzleRecipe(MuxCommand):
proto_parts = [proto_def(obj) for obj in parts]
proto_results = [proto_def(obj) for obj in results]
puzzle = create_script(PuzzleRecipe, key=puzzle_name)
puzzle = create_script(PuzzleRecipe, key=puzzle_name, persistent=True)
puzzle.save_recipe(puzzle_name, proto_parts, proto_results)
puzzle.locks.add("control:id(%s) or perm(Builder)" % caller.dbref[1:])
@ -488,7 +488,7 @@ class CmdArmPuzzle(MuxCommand):
Notes:
Create puzzles with `@puzzle`; get list of
defined puzzles using `@lspuzlerecipies`.
defined puzzles using `@lspuzzlerecipes`.
"""
@ -588,13 +588,27 @@ def _matching_puzzles(puzzles, puzzlename_tags_dict, puzzle_ingredients):
class CmdUsePuzzleParts(MuxCommand):
"""
Use an object, or a group of objects at once.
Example:
You look around you and see a pole, a long string, and a needle.
use pole, long string, needle
Genius! You built a fishing pole.
Usage:
use <obj1> [,obj2,...]
"""
# Technical explanation
"""
Searches for all puzzles whose parts match the given set of objects. If there are matching
puzzles, the result objects are spawned in their corresponding location if all parts have been
passed in.
Usage:
use <part1[,part2,...>]
"""
key = "use"

View file

@ -331,7 +331,7 @@ class LanguageHandler(DefaultScript):
# find out what preceeded this word
wpos = match.start()
preceeding = match.string[:wpos].strip()
start_sentence = preceeding.endswith(".") or not preceeding
start_sentence = preceeding.endswith((".", "!", "?")) or not preceeding
# make up translation on the fly. Length can
# vary from un-translated word.

View file

@ -283,7 +283,7 @@ def parse_language(speaker, emote):
the markers and a tuple (langname, saytext), where
langname can be None.
Raises:
LanguageError: If an invalid language was specified.
rplanguage.LanguageError: If an invalid language was specified.
Notes:
Note that no errors are raised if the wrong language identifier
@ -798,6 +798,16 @@ class RecogHandler(object):
# recog_mask log not passed, disable recog
return obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key
def all(self):
"""
Get a mapping of the recogs stored in handler.
Returns:
recogs (dict): A mapping of {recog: obj} stored in handler.
"""
return {self.obj2recog[obj]: obj for obj in self.obj2recog.keys()}
def remove(self, obj):
"""
Clear recog for a given object.
@ -896,10 +906,9 @@ class CmdSay(RPCommand): # replaces standard say
caller.msg("Say what?")
return
# calling the speech hook on the location
speech = caller.location.at_before_say(self.args)
# calling the speech modifying hook
speech = caller.at_before_say(self.args)
# preparing the speech with sdesc/speech parsing.
speech = '/me says, "{speech}"'.format(speech=speech)
targets = self.caller.location.contents
send_emote(self.caller, targets, speech, anonymous_add=None)
@ -932,6 +941,9 @@ class CmdSdesc(RPCommand): # set/look at own sdesc
except SdescError as err:
caller.msg(err)
return
except AttributeError:
caller.msg(f"Cannot set sdesc on {caller.key}.")
return
caller.msg("%s's sdesc was set to '%s'." % (caller.key, sdesc))
@ -1041,6 +1053,7 @@ class CmdRecog(RPCommand): # assign personal alias to object in room
Recognize another person in the same room.
Usage:
recog
recog sdesc as alias
forget alias
@ -1048,8 +1061,8 @@ class CmdRecog(RPCommand): # assign personal alias to object in room
recog tall man as Griatch
forget griatch
This will assign a personal alias for a person, or
forget said alias.
This will assign a personal alias for a person, or forget said alias.
Using the command without arguments will list all current recogs.
"""
@ -1058,6 +1071,7 @@ class CmdRecog(RPCommand): # assign personal alias to object in room
def parse(self):
"Parse for the sdesc as alias structure"
self.sdesc, self.alias = "", ""
if " as " in self.args:
self.sdesc, self.alias = [part.strip() for part in self.args.split(" as ", 2)]
elif self.args:
@ -1070,22 +1084,47 @@ class CmdRecog(RPCommand): # assign personal alias to object in room
def func(self):
"Assign the recog"
caller = self.caller
if not self.args:
caller.msg("Usage: recog <sdesc> as <alias> or forget <alias>")
return
sdesc = self.sdesc
alias = self.alias.rstrip(".?!")
sdesc = self.sdesc
recog_mode = self.cmdstring != "forget" and alias and sdesc
forget_mode = self.cmdstring == "forget" and sdesc
list_mode = not self.args
if not (recog_mode or forget_mode or list_mode):
caller.msg("Usage: recog, recog <sdesc> as <alias> or forget <alias>")
return
if list_mode:
# list all previously set recogs
all_recogs = caller.recog.all()
if not all_recogs:
caller.msg(
"You recognize no-one. " "(Use 'recog <sdesc> as <alias>' to recognize people."
)
else:
# note that we don't skip those failing enable_recog lock here,
# because that would actually reveal more than we want.
lst = "\n".join(
" {} ({})".format(key, obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key)
for key, obj in all_recogs.items()
)
caller.msg(
f"Currently recognized (use 'recog <sdesc> as <alias>' to add "
f"new and 'forget <alias>' to remove):\n{lst}"
)
return
prefixed_sdesc = sdesc if sdesc.startswith(_PREFIX) else _PREFIX + sdesc
candidates = caller.location.contents
matches = parse_sdescs_and_recogs(caller, candidates, prefixed_sdesc, search_mode=True)
nmatches = len(matches)
# handle 0, 1 and >1 matches
# handle 0 and >1 matches
if nmatches == 0:
caller.msg(_EMOTE_NOMATCH_ERROR.format(ref=sdesc))
elif nmatches > 1:
reflist = [
"%s%s%s (%s%s)"
% (
"{}{}{} ({}{})".format(
inum + 1,
_NUM_SEP,
_RE_PREFIX.sub("", sdesc),
@ -1095,17 +1134,20 @@ class CmdRecog(RPCommand): # assign personal alias to object in room
for inum, obj in enumerate(matches)
]
caller.msg(_EMOTE_MULTIMATCH_ERROR.format(ref=sdesc, reflist="\n ".join(reflist)))
else:
# one single match
obj = matches[0]
if not obj.access(self.obj, "enable_recog", default=True):
# don't apply recog if object doesn't allow it (e.g. by being masked).
caller.msg("Can't recognize someone who is masked.")
caller.msg("It's impossible to recognize them.")
return
if self.cmdstring == "forget":
if forget_mode:
# remove existing recog
caller.recog.remove(obj)
caller.msg("%s will now know only '%s'." % (caller.key, obj.recog.get(obj)))
caller.msg("%s will now know them only as '%s'." % (caller.key, obj.recog.get(obj)))
else:
# set recog
sdesc = obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key
try:
alias = caller.recog.add(obj, alias)
@ -1378,7 +1420,7 @@ class ContribRPObject(DefaultObject):
looker (TypedObject): The object or account that is looking
at/getting inforamtion for this object.
Kwargs:
Keyword Args:
pose (bool): Include the pose (if available) in the return.
Returns:
@ -1466,7 +1508,7 @@ class ContribRPCharacter(DefaultCharacter, ContribRPObject):
looker (TypedObject): The object or account that is looking
at/getting inforamtion for this object.
Kwargs:
Keyword Args:
pose (bool): Include the pose (if available) in the return.
Returns:
@ -1509,6 +1551,20 @@ class ContribRPCharacter(DefaultCharacter, ContribRPObject):
# initializing sdesc
self.sdesc.add("A normal person")
def at_before_say(self, message, **kwargs):
"""
Called before the object says or whispers anything, return modified message.
Args:
message (str): The suggested say/whisper text spoken by self.
Keyword Args:
whisper (bool): If True, this is a whisper rather than a say.
"""
if kwargs.get("whisper"):
return f'/me whispers "{message}"'
return f'/me says, "{message}"'
def process_sdesc(self, sdesc, obj, **kwargs):
"""
Allows to customize how your sdesc is displayed (primarily by

View file

@ -70,7 +70,7 @@ class AuditedServerSession(ServerSession):
Extracts messages and system data from a Session object upon message
send or receive.
Kwargs:
Keyword Args:
src (str): Source of data; 'client' or 'server'. Indicates direction.
text (str or list): Client sends messages to server in the form of
lists. Server sends messages to client as string.
@ -216,7 +216,7 @@ class AuditedServerSession(ServerSession):
"""
Generic hook for sending data out through the protocol.
Kwargs:
Keyword Args:
kwargs (any): Other data to the protocol.
"""
@ -234,7 +234,7 @@ class AuditedServerSession(ServerSession):
"""
Hook for protocols to send incoming data to the engine.
Kwargs:
Keyword Args:
kwargs (any): Other data from the protocol.
"""

View file

@ -169,6 +169,8 @@ class TestRPSystem(EvenniaTest):
self.speaker.recog.remove(self.receiver1)
self.assertEqual(self.speaker.recog.get(self.receiver1), sdesc1)
self.assertEqual(self.speaker.recog.all(), {"Mr Receiver2": self.receiver2})
def test_parse_language(self):
self.assertEqual(
rpsystem.parse_language(self.speaker, emote),
@ -233,6 +235,49 @@ class TestRPSystem(EvenniaTest):
self.assertEqual(self.speaker.search("colliding"), self.receiver2)
class TestRPSystemCommands(CommandTest):
def setUp(self):
super().setUp()
self.char1.swap_typeclass(rpsystem.ContribRPCharacter)
self.char2.swap_typeclass(rpsystem.ContribRPCharacter)
def test_commands(self):
self.call(
rpsystem.CmdSdesc(), "Foobar Character", "Char's sdesc was set to 'Foobar Character'."
)
self.call(
rpsystem.CmdSdesc(),
"BarFoo Character",
"Char2's sdesc was set to 'BarFoo Character'.",
caller=self.char2,
)
self.call(rpsystem.CmdSay(), "Hello!", 'Char says, "Hello!"')
self.call(rpsystem.CmdEmote(), "/me smiles to /barfoo.", "Char smiles to BarFoo Character")
self.call(
rpsystem.CmdPose(),
"stands by the bar",
"Pose will read 'Foobar Character stands by the bar.'.",
)
self.call(
rpsystem.CmdRecog(),
"barfoo as friend",
"Char will now remember BarFoo Character as friend.",
)
self.call(
rpsystem.CmdRecog(),
"",
"Currently recognized (use 'recog <sdesc> as <alias>' to add new "
"and 'forget <alias>' to remove):\n friend (BarFoo Character)",
)
self.call(
rpsystem.CmdRecog(),
"friend",
"Char will now know them only as 'BarFoo Character'",
cmdstring="forget",
)
# Testing of ExtendedRoom contrib
from django.conf import settings
@ -607,7 +652,7 @@ class TestWilderness(EvenniaTest):
"west": (0, 1),
"northwest": (0, 2),
}
for direction, correct_loc in directions.items(): # Not compatible with Python 3
for (direction, correct_loc) in directions.items(): # Not compatible with Python 3
new_loc = wilderness.get_new_coordinates(loc, direction)
self.assertEqual(new_loc, correct_loc, direction)
@ -829,20 +874,17 @@ class TestCustomGameTime(EvenniaTest):
# Test dice module
from evennia.contrib import dice # noqa
@patch("random.randint", return_value=5)
@patch("evennia.contrib.dice.randint", return_value=5)
class TestDice(CommandTest):
def test_roll_dice(self, mocked_randint):
# we must import dice here for the mocked randint to apply correctly.
from evennia.contrib import dice
self.assertEqual(dice.roll_dice(6, 6, modifier=("+", 4)), mocked_randint() * 6 + 4)
self.assertEqual(dice.roll_dice(6, 6, conditional=("<", 35)), True)
self.assertEqual(dice.roll_dice(6, 6, conditional=(">", 33)), False)
def test_cmddice(self, mocked_randint):
from evennia.contrib import dice
self.call(
dice.CmdDice(), "3d6 + 4", "You roll 3d6 + 4.| Roll(s): 5, 5 and 5. Total result is 19."
)
@ -853,7 +895,7 @@ class TestDice(CommandTest):
# Test email-login
from evennia.contrib import email_login
from evennia.contrib import email_login # noqa
class TestEmailLogin(CommandTest):

View file

@ -279,7 +279,7 @@ def spend_action(character, actions, action_name=None):
character (obj): Character spending the action
actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions
Kwargs:
Keyword Args:
action_name (str or None): If a string is given, sets character's last action in
combat to provided string
"""
@ -441,11 +441,11 @@ class TBBasicTurnHandler(DefaultScript):
"""
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
character.db.combat_actionsleft = (
0
) # Actions remaining - start of turn adds to this, turn ends when it reaches 0
0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
)
character.db.combat_turnhandler = (
self
) # Add a reference to this turn handler script to the character
self # Add a reference to this turn handler script to the character
)
character.db.combat_lastaction = "null" # Track last action taken in combat
def start_turn(self, character):

View file

@ -218,10 +218,10 @@ def apply_damage(defender, damage):
def at_defeat(defeated):
"""
Announces the defeat of a fighter in combat.
Args:
defeated (obj): Fighter that's been defeated.
Notes:
All this does is announce a defeat message by default, but if you
want anything else to happen to defeated fighters (like putting them
@ -330,7 +330,7 @@ def spend_action(character, actions, action_name=None):
character (obj): Character spending the action
actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions
Kwargs:
Keyword Args:
action_name (str or None): If a string is given, sets character's last action in
combat to provided string
"""
@ -438,11 +438,11 @@ class TBEquipTurnHandler(DefaultScript):
"""
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
character.db.combat_actionsleft = (
0
) # Actions remaining - start of turn adds to this, turn ends when it reaches 0
0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
)
character.db.combat_turnhandler = (
self
) # Add a reference to this turn handler script to the character
self # Add a reference to this turn handler script to the character
)
character.db.combat_lastaction = "null" # Track last action taken in combat
def start_turn(self, character):
@ -553,8 +553,8 @@ class TBEWeapon(DefaultObject):
self.db.damage_range = (15, 25) # Minimum and maximum damage on hit
self.db.accuracy_bonus = 0 # Bonus to attack rolls (or penalty if negative)
self.db.weapon_type_name = (
"weapon"
) # Single word for weapon - I.E. "dagger", "staff", "scimitar"
"weapon" # Single word for weapon - I.E. "dagger", "staff", "scimitar"
)
def at_drop(self, dropper):
"""
@ -903,10 +903,10 @@ class CmdCombatHelp(CmdHelp):
class CmdWield(Command):
"""
Wield a weapon you are carrying
Usage:
wield <weapon>
Select a weapon you are carrying to wield in combat. If
you are already wielding another weapon, you will switch
to the weapon you specify instead. Using this command in
@ -933,7 +933,7 @@ class CmdWield(Command):
weapon = self.caller.search(self.args, candidates=self.caller.contents)
if not weapon:
return
if not weapon.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEWeapon"):
if not weapon.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEWeapon", exact=True):
self.caller.msg("That's not a weapon!")
# Remember to update the path to the weapon typeclass if you move this module!
return
@ -955,10 +955,10 @@ class CmdWield(Command):
class CmdUnwield(Command):
"""
Stop wielding a weapon.
Usage:
unwield
After using this command, you will stop wielding any
weapon you are currently wielding and become unarmed.
"""
@ -986,12 +986,12 @@ class CmdUnwield(Command):
class CmdDon(Command):
"""
Don armor that you are carrying
Usage:
don <armor>
Select armor to wear in combat. You can't use this
command in the middle of a fight. Use the "doff"
command in the middle of a fight. Use the "doff"
command to remove any armor you are wearing.
"""
@ -1012,7 +1012,7 @@ class CmdDon(Command):
armor = self.caller.search(self.args, candidates=self.caller.contents)
if not armor:
return
if not armor.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEArmor"):
if not armor.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEArmor", exact=True):
self.caller.msg("That's not armor!")
# Remember to update the path to the armor typeclass if you move this module!
return
@ -1031,10 +1031,10 @@ class CmdDon(Command):
class CmdDoff(Command):
"""
Stop wearing armor.
Usage:
doff
After using this command, you will stop wearing any
armor you are currently using and become unarmored.
You can't use this command in combat.

View file

@ -348,7 +348,7 @@ def spend_action(character, actions, action_name=None):
character (obj): Character spending the action
actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions
Kwargs:
Keyword Args:
action_name (str or None): If a string is given, sets character's last action in
combat to provided string
"""
@ -718,11 +718,11 @@ class TBItemsTurnHandler(DefaultScript):
"""
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
character.db.combat_actionsleft = (
0
) # Actions remaining - start of turn adds to this, turn ends when it reaches 0
0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
)
character.db.combat_turnhandler = (
self
) # Add a reference to this turn handler script to the character
self # Add a reference to this turn handler script to the character
)
character.db.combat_lastaction = "null" # Track last action taken in combat
def start_turn(self, character):

View file

@ -44,6 +44,12 @@ instead of the default:
class Character(TBMagicCharacter):
Note: If your character already existed you need to also make sure
to re-run the creation hooks on it to set the needed Attributes.
Use `update self` to try on yourself or use py to call `at_object_creation()`
on all existing Characters.
Next, import this module into your default_cmdsets.py module:
from evennia.contrib.turnbattle import tb_magic
@ -199,10 +205,10 @@ def apply_damage(defender, damage):
def at_defeat(defeated):
"""
Announces the defeat of a fighter in combat.
Args:
defeated (obj): Fighter that's been defeated.
Notes:
All this does is announce a defeat message by default, but if you
want anything else to happen to defeated fighters (like putting them
@ -298,7 +304,7 @@ def spend_action(character, actions, action_name=None):
character (obj): Character spending the action
actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions
Kwargs:
Keyword Args:
action_name (str or None): If a string is given, sets character's last action in
combat to provided string
"""
@ -332,7 +338,7 @@ class TBMagicCharacter(DefaultCharacter):
"""
Called once, when this object is first created. This is the
normal hook to overload for most object types.
Adds attributes for a character's current and maximum HP.
We're just going to set this value at '100' by default.
@ -464,11 +470,11 @@ class TBMagicTurnHandler(DefaultScript):
"""
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
character.db.combat_actionsleft = (
0
) # Actions remaining - start of turn adds to this, turn ends when it reaches 0
0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
)
character.db.combat_turnhandler = (
self
) # Add a reference to this turn handler script to the character
self # Add a reference to this turn handler script to the character
)
character.db.combat_lastaction = "null" # Track last action taken in combat
def start_turn(self, character):
@ -731,26 +737,26 @@ class CmdDisengage(Command):
class CmdLearnSpell(Command):
"""
Learn a magic spell.
Usage:
learnspell <spell name>
Adds a spell by name to your list of spells known.
The following spells are provided as examples:
|wmagic missile|n (3 MP): Fires three missiles that never miss. Can target
up to three different enemies.
|wflame shot|n (3 MP): Shoots a high-damage jet of flame at one target.
|wcure wounds|n (5 MP): Heals damage on one target.
|wmass cure wounds|n (10 MP): Like 'cure wounds', but can heal up to 5
targets at once.
|wfull heal|n (12 MP): Heals one target back to full HP.
|wcactus conjuration|n (2 MP): Creates a cactus.
"""
@ -803,10 +809,10 @@ class CmdCast(MuxCommand):
"""
Cast a magic spell that you know, provided you have the MP
to spend on its casting.
Usage:
cast <spellname> [= <target1>, <target2>, etc...]
Some spells can be cast on multiple targets, some can be cast
on only yourself, and some don't need a target specified at all.
Typing 'cast' by itself will give you a list of spells you know.
@ -818,7 +824,7 @@ class CmdCast(MuxCommand):
def func(self):
"""
This performs the actual command.
Note: This is a quite long command, since it has to cope with all
the different circumstances in which you may or may not be able
to cast a spell. None of the spell's effects are handled by the
@ -1123,7 +1129,7 @@ in the docstring for each function.
def spell_healing(caster, spell_name, targets, cost, **kwargs):
"""
Spell that restores HP to a target or targets.
kwargs:
healing_range (tuple): Minimum and maximum amount healed to
each target. (20, 40) by default.
@ -1156,7 +1162,7 @@ def spell_healing(caster, spell_name, targets, cost, **kwargs):
def spell_attack(caster, spell_name, targets, cost, **kwargs):
"""
Spell that deals damage in combat. Similar to resolve_attack.
kwargs:
attack_name (tuple): Single and plural describing the sort of
attack or projectile that strikes each enemy.
@ -1250,12 +1256,12 @@ def spell_attack(caster, spell_name, targets, cost, **kwargs):
def spell_conjure(caster, spell_name, targets, cost, **kwargs):
"""
Spell that creates an object.
kwargs:
obj_key (str): Key of the created object.
obj_desc (str): Desc of the created object.
obj_typeclass (str): Typeclass path of the object.
If you want to make more use of this particular spell funciton,
you may want to modify it to use the spawner (in evennia.utils.spawner)
instead of creating objects directly.
@ -1300,7 +1306,7 @@ parameters, some of which are required and others which are optional.
Required values for spells:
cost (int): MP cost of casting the spell
cost (int): MP cost of casting the spell
target (str): Valid targets for the spell. Can be any of:
"none" - No target needed
"self" - Self only
@ -1312,9 +1318,9 @@ Required values for spells:
spellfunc (callable): Function that performs the action of the spell.
Must take the following arguments: caster (obj), spell_name (str),
targets (list), and cost (int), as well as **kwargs.
Optional values for spells:
combat_spell (bool): If the spell can be cast in combat. True by default.
noncombat_spell (bool): If the spell can be cast out of combat. True by default.
max_targets (int): Maximum number of objects that can be targeted by the spell.

View file

@ -78,7 +78,7 @@ And change your game's character typeclass to inherit from TBRangeCharacter
instead of the default:
class Character(TBRangeCharacter):
Do the same thing in your game's objects.py module for TBRangeObject:
from evennia.contrib.turnbattle.tb_range import TBRangeObject
@ -246,10 +246,10 @@ def apply_damage(defender, damage):
def at_defeat(defeated):
"""
Announces the defeat of a fighter in combat.
Args:
defeated (obj): Fighter that's been defeated.
Notes:
All this does is announce a defeat message by default, but if you
want anything else to happen to defeated fighters (like putting them
@ -300,11 +300,11 @@ def resolve_attack(attacker, defender, attack_type, attack_value=None, defense_v
def get_range(obj1, obj2):
"""
Gets the combat range between two objects.
Args:
obj1 (obj): First object
obj2 (obj): Second object
Returns:
range (int or None): Distance between two objects or None if not applicable
"""
@ -324,7 +324,7 @@ def get_range(obj1, obj2):
def distance_inc(mover, target):
"""
Function that increases distance in range field between mover and target.
Args:
mover (obj): The object moving
target (obj): The object to be moved away from
@ -340,11 +340,11 @@ def distance_inc(mover, target):
def approach(mover, target):
"""
Manages a character's whole approach, including changes in ranges to other characters.
Args:
mover (obj): The object moving
target (obj): The object to be moved toward
Notes:
The mover will also automatically move toward any objects that are closer to the
target than the mover is. The mover will also move away from anything they started
@ -354,7 +354,7 @@ def approach(mover, target):
def distance_dec(mover, target):
"""
Helper function that decreases distance in range field between mover and target.
Args:
mover (obj): The object moving
target (obj): The object to be moved toward
@ -388,11 +388,11 @@ def approach(mover, target):
def withdraw(mover, target):
"""
Manages a character's whole withdrawal, including changes in ranges to other characters.
Args:
mover (obj): The object moving
target (obj): The object to be moved away from
Notes:
The mover will also automatically move away from objects that are close to the target
of their withdrawl. The mover will never inadvertently move toward anything else while
@ -470,7 +470,7 @@ def spend_action(character, actions, action_name=None):
character (obj): Character spending the action
actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions
Kwargs:
Keyword Args:
action_name (str or None): If a string is given, sets character's last action in
combat to provided string
"""
@ -540,7 +540,8 @@ class TBRangeTurnHandler(DefaultScript):
room as its object.
Fights persist until only one participant is left with any HP or all
remaining participants choose to end the combat with the 'disengage' command.
remaining participants choose to end the combat with the 'disengage'
command.
"""
def at_script_creation(self):
@ -615,7 +616,7 @@ class TBRangeTurnHandler(DefaultScript):
def init_range(self, to_init):
"""
Initializes range values for an object at the start of a fight.
Args:
to_init (object): Object to initialize range field for.
"""
@ -638,14 +639,14 @@ class TBRangeTurnHandler(DefaultScript):
def join_rangefield(self, to_init, anchor_obj=None, add_distance=0):
"""
Adds a new object to the range field of a fight in progress.
Args:
to_init (object): Object to initialize range field for.
Kwargs:
Keyword Args:
anchor_obj (object): Object to copy range values from, or None for a random object.
add_distance (int): Distance to put between to_init object and anchor object.
"""
# Get a list of room's contents without to_init object.
contents = self.obj.contents
@ -674,11 +675,11 @@ class TBRangeTurnHandler(DefaultScript):
"""
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
character.db.combat_actionsleft = (
0
) # Actions remaining - start of turn adds to this, turn ends when it reaches 0
0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
)
character.db.combat_turnhandler = (
self
) # Add a reference to this turn handler script to the character
self # Add a reference to this turn handler script to the character
)
character.db.combat_lastaction = "null" # Track last action taken in combat
def start_turn(self, character):

View file

@ -104,36 +104,34 @@ tutorial
# ... and describe it.
#
@desc
|gWelcome to the Evennia tutorial!|n
|gWelcome to the Evennia tutorial-world!|n
This small quest shows some examples of Evennia usage.
|gDo you want help with how to play? Write |yintro|g to get an introduction to
Evennia and the basics of playing!|n
To get into the mood of this miniature quest, imagine you are an adventurer
out to find fame and fortune. You have heard rumours of an old castle ruin by
the coast. In its depth a warrior princess was buried together with her
powerful magical weapon - a valuable prize, if it's true. Of course this is a
chance to adventure that you cannot turn down!
You reach the coast in the midst of a raging thunderstorm. With wind and rain
screaming in your face you stand where the moor meet the sea along a high,
rocky coast ...
Try '|yintro|n' for usage help. During the quest, write '|ytutorial|n' to get
behind-the-scenes help anywhere, and '|ygive up|n' to abandon the quest.
|gwrite 'begin' to start your quest!|n
The following tutorial consists of a small single-player quest
area. The various rooms are designed to show off some of the power
and possibilities of the Evennia mud creation system. At any time
during this tutorial you can use the |wtutorial|n (or |wtut|n)
command to get some background info about the room or certain objects
to see what is going on "behind the scenes".
To get into the mood of this miniature quest, imagine you are an
adventurer out to find fame and fortune. You have heard rumours of an
old castle ruin by the coast. In its depth a warrior princess was
buried together with her powerful magical weapon - a valuable prize,
if it's true. Of course this is a chance to adventure that you
cannot turn down!
You reach the coast in the midst of a raging thunderstorm. With wind
and rain screaming in your face you stand where the moor meet the sea
along a high, rocky coast ...
|g(write 'start' or 'begin' to start the tutorial. Try 'tutorial'
to get behind-the-scenes help anywhere.)|n
#
# Show that the tutorial command works ...
#
@set here/tutorial_info =
You just tried the tutorial command. Use it in various rooms to see
You just tried the |wtutorial|G command. Use it in various rooms to see
what's technically going on and what you could try in each room. The
intro room assigns some properties to your character, like a simple
"health" property used when fighting. Other rooms and puzzles might do
@ -294,14 +292,14 @@ start
on the sign.
# Set a climbable object for discovering a hidden exit
#
@create/drop gnarled old trees;tree;trees;gnarled : tutorial_world.objects.TutorialClimbable
@create/drop gnarled old tree;tree;trees;gnarled : tutorial_world.objects.TutorialClimbable
#
@desc trees = Only the sturdiest of trees survive at the edge of the
moor. A small group of huddling black things has dug in near the
@desc tree = Only the sturdiest of trees survive at the edge of the
moor. A small huddling black thing has dug in near the
cliff edge, eternally pummeled by wind and salt to become an integral
part of the gloomy scenery.
#
@lock trees = get:false()
@lock tree = get:false()
#
@set trees/get_err_msg =
The group of old trees have withstood the eternal wind for hundreds
@ -475,7 +473,7 @@ north
# regular exits back to the cliff, that is handled by the bridge
# typeclass itself.
#
@dig The old bridge;bridge;tut#05
@dig The old bridge;bridge;east;e;tut#05
: tutorial_world.rooms.BridgeRoom
= old bridge;east;e;bridge;hangbridge
#

View file

@ -0,0 +1,781 @@
"""
Intro menu / game tutor
Evennia contrib - Griatch 2020
This contrib is an intro-menu for general MUD and evennia usage using the
EvMenu menu-templating system.
EvMenu templating is a way to create a menu using a string-format instead
of creating all nodes manually. Of course, for full functionality one must
still create the goto-callbacks.
"""
from evennia import create_object
from evennia import CmdSet
from evennia.utils.evmenu import parse_menu_template, EvMenu
# Goto callbacks and helper resources for the menu
def do_nothing(caller, raw_string, **kwargs):
"""
Re-runs the current node
"""
return None
def send_testing_tagged(caller, raw_string, **kwargs):
"""
Test to send a message to a pane tagged with 'testing' in the webclient.
"""
caller.msg(
(
"This is a message tagged with 'testing' and "
"should appear in the pane you selected!\n "
f"You wrote: '{raw_string}'",
{"type": "testing"},
)
)
return None
# Resources for the first help-command demo
class DemoCommandSetHelp(CmdSet):
"""
Demo the help command
"""
key = "Help Demo Set"
priority = 2
def at_cmdset_creation(self):
from evennia import default_cmds
self.add(default_cmds.CmdHelp())
def goto_command_demo_help(caller, raw_string, **kwargs):
"Sets things up before going to the help-demo node"
_maintain_demo_room(caller, delete=True)
caller.cmdset.remove(DemoCommandSetRoom)
caller.cmdset.remove(DemoCommandSetComms)
caller.cmdset.add(DemoCommandSetHelp) # TODO - make persistent
return kwargs.get("gotonode") or "command_demo_help"
# Resources for the comms demo
class DemoCommandSetComms(CmdSet):
"""
Demo communications
"""
key = "Color Demo Set"
priority = 2
no_exits = True
no_objs = True
def at_cmdset_creation(self):
from evennia import default_cmds
self.add(default_cmds.CmdHelp())
self.add(default_cmds.CmdSay())
self.add(default_cmds.CmdPose())
self.add(default_cmds.CmdPage())
self.add(default_cmds.CmdColorTest())
def goto_command_demo_comms(caller, raw_string, **kwargs):
"""
Setup and go to the color demo node.
"""
caller.cmdset.remove(DemoCommandSetHelp)
caller.cmdset.remove(DemoCommandSetRoom)
caller.cmdset.add(DemoCommandSetComms)
return kwargs.get("gotonode") or "comms_demo_start"
# Resources for the room demo
_ROOM_DESC = """
This is a small and comfortable wood cabin. Bright sunlight is shining in
through the windows.
Use |ylook sign|n or |yl sign|n to examine the wooden sign nailed to the wall.
"""
_SIGN_DESC = """
The small sign reads:
Good! Now try '|ylook small|n'.
... You'll get a multi-match error! There are two things that 'small' could
refer to here - the 'small wooden sign' or the 'small, cozy cabin' itself. You will
get a list of the possibilities.
You could either tell Evennia which one you wanted by picking a unique part
of their name (like '|ylook cozy|n') or use the number in the list to pick
the one you want, like this:
|ylook 2-small|n
As long as what you write is uniquely identifying you can be lazy and not
write the full name of the thing you want to look at. Try '|ylook bo|n',
'|yl co|n' or '|yl 1-sm|n'!
... Oh, and if you see database-ids like (#1245) by the name of objects,
it's because you are playing with Builder-privileges or higher. Regular
players will not see the numbers.
Next try |ylook door|n.
"""
_DOOR_DESC_OUT = """
This is a solid wooden door leading to the outside of the cabin. Some
text is written on it:
This is an |wexit|n. An exit is often named by its compass-direction like
|weast|n, |wwest|n, |wnorthwest|n and so on, but it could be named
anything, like this door. To use the exit, you just write its name. So by
writing |ydoor|n you will leave the cabin.
"""
_DOOR_DESC_IN = """
This is a solid wooden door leading to the inside of the cabin. On
are some carved text:
This exit leads back into the cabin. An exit is just like any object,
so while has a name, it can also have aliases. To get back inside
you can both write |ydoor|n but also |yin|n.
"""
_MEADOW_DESC = """
This is a lush meadow, just outside a cozy cabin. It's surrounded
by trees and sunlight filters down from a clear blue sky.
There is a |wstone|n here. Try looking at it!
"""
_STONE_DESC = """
This is a fist-sized stone covered in runes:
To pick me up, use
|yget stone|n
You can see what you carry with the |yinventory|n (|yi|n).
To drop me again, just write
|ydrop stone|n
Use |ynext|n when you are done exploring and want to
continue with the tutorial.
"""
def _maintain_demo_room(caller, delete=False):
"""
Handle the creation/cleanup of demo assets. We store them
on the character and clean them when leaving the menu later.
"""
# this is a tuple (room, obj)
roomdata = caller.db.tutorial_world_demo_room_data
if delete:
if roomdata:
# we delete directly for simplicity. We need to delete
# in specific order to avoid deleting rooms moves
# its contents to their default home-location
prev_loc, room1, sign, room2, stone, door_out, door_in = roomdata
caller.location = prev_loc
sign.delete()
stone.delete()
door_out.delete()
door_in.delete()
room1.delete()
room2.delete()
del caller.db.tutorial_world_demo_room_data
elif not roomdata:
# create and describe the cabin and box
room1 = create_object("evennia.objects.objects.DefaultRoom", key="A small, cozy cabin")
room1.db.desc = _ROOM_DESC.lstrip()
sign = create_object(
"evennia.objects.objects.DefaultObject", key="small wooden sign", location=room1
)
sign.db.desc = _SIGN_DESC.strip()
sign.locks.add("get:false()")
sign.db.get_err_msg = "The sign is nailed to the wall. It's not budging."
# create and describe the meadow and stone
room2 = create_object("evennia.objects.objects.DefaultRoom", key="A lush summer meadow")
room2.db.desc = _MEADOW_DESC.lstrip()
stone = create_object(
"evennia.objects.objects.DefaultObject", key="carved stone", location=room2
)
stone.db.desc = _STONE_DESC.strip()
# make the linking exits
door_out = create_object(
"evennia.objects.objects.DefaultExit",
key="Door",
location=room1,
destination=room2,
locks=["get:false()"],
)
door_out.db.desc = _DOOR_DESC_OUT.strip()
door_in = create_object(
"evennia.objects.objects.DefaultExit",
key="entrance to the cabin",
aliases=["door", "in", "entrance"],
location=room2,
destination=room1,
locks=["get:false()"],
)
door_in.db.desc = _DOOR_DESC_IN.strip()
# store references for easy removal later
caller.db.tutorial_world_demo_room_data = (
caller.location,
room1,
sign,
room2,
stone,
door_out,
door_in,
)
# move caller into room
caller.location = room1
class DemoCommandSetRoom(CmdSet):
"""
Demo some general in-game commands command.
"""
key = "Room Demo Set"
priority = 2
no_exits = False
no_objs = False
def at_cmdset_creation(self):
from evennia import default_cmds
self.add(default_cmds.CmdHelp())
self.add(default_cmds.CmdLook())
self.add(default_cmds.CmdGet())
self.add(default_cmds.CmdDrop())
self.add(default_cmds.CmdInventory())
self.add(default_cmds.CmdExamine())
self.add(default_cmds.CmdPy())
def goto_command_demo_room(caller, raw_string, **kwargs):
"""
Setup and go to the demo-room node. Generates a little 2-room environment
for testing out some commands.
"""
_maintain_demo_room(caller)
caller.cmdset.remove(DemoCommandSetHelp)
caller.cmdset.remove(DemoCommandSetComms)
caller.cmdset.add(DemoCommandSetRoom)
return "command_demo_room"
def goto_cleanup_cmdsets(caller, raw_strings, **kwargs):
"""
Cleanup all cmdsets.
"""
caller.cmdset.remove(DemoCommandSetHelp)
caller.cmdset.remove(DemoCommandSetComms)
caller.cmdset.remove(DemoCommandSetRoom)
return kwargs.get("gotonode")
# register all callables that can be used in the menu template
GOTO_CALLABLES = {
"send_testing_tagged": send_testing_tagged,
"do_nothing": do_nothing,
"goto_command_demo_help": goto_command_demo_help,
"goto_command_demo_comms": goto_command_demo_comms,
"goto_command_demo_room": goto_command_demo_room,
"goto_cleanup_cmdsets": goto_cleanup_cmdsets,
}
# Main menu definition
MENU_TEMPLATE = """
## NODE start
|g** Evennia introduction wizard **|n
If you feel lost you can learn some of the basics of how to play a text-based
game here. You can also learn a little about the system and how to find more
help. You can exit this tutorial-wizard at any time by entering '|yq|n' or '|yquit|n'.
Press |y<return>|n or write |ynext|n to step forward. Or select a number to jump to.
## OPTIONS
1 (next);1;next;n: What is a MUD/MU*? -> about_muds
2: About Evennia -> about_evennia
3: Using the webclient -> using webclient
4: The help command -> goto_command_demo_help()
5: Communicating with others -> goto_command_demo_help(gotonode='talk on channels')
6: Using colors -> goto_command_demo_comms(gotonode='testing_colors')
7: Moving and exploring -> goto_command_demo_room()
8: Conclusions & next steps-> conclusions
>: about_muds
# ---------------------------------------------------------------------------------
## NODE about_muds
|g** About MUDs **|n
The term '|wMUD|n' stands for Multi-user-Dungeon or -Dimension. A MUD is
primarily played by inserting text |wcommands|n and getting text back.
MUDS were the |wprecursors|n to graphical MMORPG-style games like World of
Warcraft. While not as mainstream as they once were, comparing a text-game to a
graphical game is like comparing a book to a movie - it's just a different
experience altogether.
MUDs are |wdifferent|n from Interactive Fiction (IF) in that they are multiplayer
and usually has a consistent game world with many stories and protagonists
acting at the same time.
Like there are many different styles of graphical MMOs, there are |wmany
variations|n of MUDs: They can be slow-paced or fast. They can cover fantasy,
sci-fi, horror or other genres. They can allow PvP or not and be casual or
hardcore, strategic, tactical, turn-based or play in real-time.
Whereas 'MUD' is arguably the most well-known term, there are other terms
centered around particular game engines - such as MUSH, MOO, MUX, MUCK, LPMuds,
ROMs, Diku and others. Many people that played MUDs in the past used one of
these existing families of text game-servers, whether they knew it or not.
|cEvennia|n is a newer text game engine designed to emulate almost any existing
gaming style you like and possibly any new ones you can come up with!
## OPTIONS
next;n: About Evennia -> about_evennia
back to start;back;start;t: start
>: about_evennia
# ---------------------------------------------------------------------------------
## NODE about_evennia
|g** About Evennia **|n
|cEvennia|n is a Python game engine for creating multiplayer online text-games
(aka MUDs, MUSHes, MUX, MOOs...). It is open-source and |wfree to use|n, also for
commercial projects (BSD license).
Out of the box, Evennia provides a |wworking, if empty game|n. Whereas you can play
via traditional telnet MUD-clients, the server runs your game's website and
offers a |wHTML5 webclient|n so that people can play your game in their browser
without downloading anything extra.
Evennia deliberately |wdoes not|n hard-code any game-specific things like
combat-systems, races, skills, etc. They would not match what just you wanted
anyway! Whereas we do have optional contribs with many examples, most of our
users use them as inspiration to make their own thing.
Evennia is developed entirely in |wPython|n, using modern developer practices.
The advantage of text is that even a solo developer or small team can
realistically make a competitive multiplayer game (as compared to a graphical
MMORPG which is one of the most expensive game types in existence to develop).
Many also use Evennia as a |wfun way to learn Python|n!
## OPTIONS
next;n: Using the webclient -> using webclient
back;b: About MUDs -> about_muds
>: using webclient
# ---------------------------------------------------------------------------------
## NODE using webclient
|g** Using the Webclient **|n
|RNote: This is only relevant if you use Evennia's HTML5 web client. If you use a
third-party (telnet) mud-client, you can skip this section.|n
Evennia's web client is (for a local install) found by pointing your browser to
|yhttp://localhost:4001/webclient|n
For a live example, the public Evennia demo can be found at
|yhttps://demo.evennia.com/webclient|n
The web client starts out having two panes - the input-pane for entering commands
and the main window.
- Use |y<Return>|n (or click the arrow on the right) to send your input.
- Use |yShift + <up/down-arrow>|n to step back and forth in your command-history.
- Use |yShift + <Return>|n to add a new line to your input without sending.
There is also some |wextra|n info to learn about customizing the webclient.
## OPTIONS
extra: Customizing the webclient -> customizing the webclient
next;n: Playing the game -> goto_command_demo_help()
back;b: About Evennia -> about_evennia
back to start;start: start
>: goto_command_demo_help()
# ---------------------------------------------------------------------------------
# this is a dead-end 'leaf' of the menu
## NODE customizing the webclient
|g** Extra hints on customizing the Webclient **|n
|y1)|n The panes of the webclient can be resized and you can create additional panes.
- Press the little plus (|w+|n) sign in the top left and a new tab will appear.
- Click and drag the tab and pull it far to the right and release when it creates two
panes next to each other.
|y2)|n You can have certain server output only appear in certain panes.
- In your new rightmost pane, click the diamond () symbol at the top.
- Unselect everything and make sure to select "testing".
- Click the diamond again so the menu closes.
- Next, write "|ytest Hello world!|n". A test-text should appear in your rightmost pane!
|y3)|n You can customize general webclient settings by pressing the cogwheel in the upper
left corner. It allows to change things like font and if the client should play sound.
The "message routing" allows for rerouting text matching a certain regular expression (regex)
to a web client pane with a specific tag that you set yourself.
|y4)|n Close the right-hand pane with the |wX|n in the rop right corner.
## OPTIONS
back;b: using webclient
> test *: send tagged message to new pane -> send_testing_tagged()
>: using webclient
# ---------------------------------------------------------------------------------
# we get here via goto_command_demo_help()
## NODE command_demo_help
|g** Playing the game **|n
Evennia has about |w90 default commands|n. They include useful administration/building
commands and a few limited "in-game" commands to serve as examples. They are intended
to be changed, extended and modified as you please.
First to try is |yhelp|n. This lists all commands |wcurrently|n available to you.
Use |yhelp <topic>|n to get specific help. Try |yhelp help|n to get help on using
the help command. For your game you could add help about your game, lore, rules etc
as well.
At the moment you only have |whelp|n and some |wChannel Names|n (the '<menu commands>'
is just a placeholder to indicate you are using this menu).
We'll add more commands as we get to them in this tutorial - but we'll only
cover a small handful. Once you exit you'll find a lot more! Now let's try
those channels ...
## OPTIONS
next;n: Talk on Channels -> talk on channels
back;b: Using the webclient -> goto_cleanup_cmdsets(gotonode='using webclient')
back to start;start: start
>: talk on channels
# ---------------------------------------------------------------------------------
## NODE talk on channels
|g** Talk on Channels **|n
|wChannels|n are like in-game chatrooms. The |wChannel Names|n help-category
holds the names of the channels available to you right now. One such channel is
|wpublic|n. Use |yhelp public|n to see how to use it. Try it:
|ypublic Hello World!|n
This will send a message to the |wpublic|n channel where everyone on that
channel can see it. If someone else is on your server, you may get a reply!
Evennia can link its in-game channels to external chat networks. This allows
you to talk with people not actually logged into the game. For
example, the online Evennia-demo links its |wpublic|n channel to the #evennia
IRC support channel.
## OPTIONS
next;n: Talk to people in-game -> goto_command_demo_comms()
back;b: Finding help -> goto_command_demo_help()
back to start;start: start
>: goto_command_demo_comms()
# ---------------------------------------------------------------------------------
# we get here via goto_command_demo_comms()
## NODE comms_demo_start
|g** Talk to people in-game **|n
You can also chat with people inside the game. If you try |yhelp|n now you'll
find you have a few more commands available for trying this out.
|ysay Hello there!|n
|y'Hello there!|n
|wsay|n is used to talk to people in the same location you are. Everyone in the
room will see what you have to say. A single quote |y'|n is a convenient shortcut.
|ypose smiles|n
|y:smiles|n
|wpose|n (or |wemote|n) describes what you do to those nearby. This is a very simple
command by default, but it can be extended to much more complex parsing in order to
include other people/objects in the emote, reference things by a short-description etc.
## OPTIONS
next;n: Paging people -> paging_people
back;b: Talk on Channels -> goto_command_demo_help(gotonode='talk on channels')
back to start;start: start
>: paging_people
# ---------------------------------------------------------------------------------
## NODE paging_people
|g** Paging people **|n
Halfway between talking on a |wChannel|n and chatting in your current location
with |wsay|n and |wpose|n, you can also |wpage|n people. This is like a private
message only they can see.
|ypage <name> = Hello there!
page <name1>, <name2> = Hello both of you!|n
If you are alone on the server, put your own name as |w<name>|n to test it and
page yourself. Write just |ypage|n to see your latest pages. This will also show
you if anyone paged you while you were offline.
(By the way - depending on which games you are used to, you may think that the
use of |y=|n above is strange. This is a MUSH/MUX-style of syntax. For your own
game you can change the |wpose|n command to work however you prefer).
## OPTIONS
next;n: Using colors -> testing_colors
back;b: Talk to people in-game -> comms_demo_start
back to start;start: start
>: testing_colors
# ---------------------------------------------------------------------------------
## NODE testing_colors
|g** U|rs|yi|gn|wg |c|yc|wo|rl|bo|gr|cs |g**|n
You can add color in your text by the help of tags. However, remember that not
everyone will see your colors - it depends on their client (and some use
screenreaders). Using color can also make text harder to read. So use it
sparingly.
To start coloring something |rred|n, add a ||r (red) marker and then
end with ||n (to go back to neutral/no-color):
|ysay This is a ||rred||n text!
say This is a ||Rdark red||n text!|n
You can also change the background:
|ysay This is a ||[x||bblue text on a light-grey background!|n
There are 16 base colors and as many background colors (called ANSI colors). Some
clients also supports so-called Xterm256 which gives a total of 256 colors. These are
given as |w||rgb|n, where r, g, b are the components of red, green and blue from 0-5:
|ysay This is ||050solid green!|n
|ysay This is ||520an orange color!|n
|ysay This is ||[005||555white on bright blue background!|n
If you don't see the expected colors from the above examples, it's because your
client does not support it - try out the Evennia webclient instead. To see all
color codes printed, try
|ycolor ansi
|ycolor xterm
## OPTIONS
next;n: Moving and Exploring -> goto_command_demo_room()
back;b: Paging people -> goto_command_demo_comms(gotonode='paging_people')
back to start;start: start
>: goto_command_demo_room()
# ---------------------------------------------------------------------------------
# we get here via goto_command_demo_room()
## NODE command_demo_room
|gMoving and Exploring|n
For exploring the game, a very important command is '|ylook|n'. It's also
abbreviated '|yl|n' since it's used so much. Looking displays/redisplays your
current location. You can also use it to look closer at items in the world. So
far in this tutorial, using 'look' would just redisplay the menu.
Try |ylook|n now. You have been quietly transported to a sunny cabin to look
around in. Explore a little and use |ynext|n when you are done.
## OPTIONS
next;n: Conclusions -> conclusions
back;b: Channel commands -> goto_command_demo_comms(gotonode='testing_colors')
back to start;start: start
>: conclusions
# ---------------------------------------------------------------------------------
## NODE conclusions
|gConclusions|n
That concludes this little quick-intro to using the base game commands of
Evennia. With this you should be able to continue exploring and also find help
if you get stuck!
Write |ynext|n to end this wizard and continue to the tutorial-world quest!
If you want there is also some |wextra|n info for where to go beyond that.
## OPTIONS
extra: Where to go next -> post scriptum
next;next;n: End -> end
back;b: Moving and Exploring -> goto_command_demo_room()
back to start;start: start
>: end
# ---------------------------------------------------------------------------------
## NODE post scriptum
|gWhere to next?|n
After playing through the tutorial-world quest, if you aim to make a game with
Evennia you are wise to take a look at the |wEvennia documentation|n at
|yhttps://github.com/evennia/evennia/wiki|n
- You can start by trying to build some stuff by following the |wBuilder quick-start|n:
|yhttps://github.com/evennia/evennia/wiki/Building-Quickstart|n
- The tutorial-world may or may not be your cup of tea, but it does show off
several |wuseful tools|n of Evennia. You may want to check out how it works:
|yhttps://github.com/evennia/evennia/wiki/Tutorial-World-Introduction|n
- You can then continue looking through the |wTutorials|n and pick one that
fits your level of understanding.
|yhttps://github.com/evennia/evennia/wiki/Tutorials|n
- Make sure to |wjoin our forum|n and connect to our |wsupport chat|n! The
Evennia community is very active and friendly and no question is too simple.
You will often quickly get help. You can everything you need linked from
|yhttp://www.evennia.com|n
# ---------------------------------------------------------------------------------
## OPTIONS
back: conclusions
>: conclusions
## NODE end
|gGood luck!|n
"""
# -------------------------------------------------------------------------------------------
#
# EvMenu implementation and access function
#
# -------------------------------------------------------------------------------------------
class TutorialEvMenu(EvMenu):
"""
Custom EvMenu for displaying the intro-menu
"""
def close_menu(self):
"""Custom cleanup actions when closing menu"""
self.caller.cmdset.remove(DemoCommandSetHelp)
self.caller.cmdset.remove(DemoCommandSetRoom)
self.caller.cmdset.remove(DemoCommandSetComms)
_maintain_demo_room(self.caller, delete=True)
super().close_menu()
def options_formatter(self, optionslist):
navigation_keys = ("next", "back", "back to start")
other = []
navigation = []
for key, desc in optionslist:
if key in navigation_keys:
desc = f" ({desc})" if desc else ""
navigation.append(f"|lc{key}|lt|w{key}|n|le{desc}")
else:
other.append((key, desc))
navigation = (
(" " + " |W|||n ".join(navigation) + " |W|||n " + "|wQ|Wuit|n") if navigation else ""
)
other = super().options_formatter(other)
sep = "\n\n" if navigation and other else ""
return f"{navigation}{sep}{other}"
def init_menu(caller):
"""
Call to initialize the menu.
"""
menutree = parse_menu_template(caller, MENU_TEMPLATE, GOTO_CALLABLES)
TutorialEvMenu(caller, menutree)

View file

@ -377,6 +377,11 @@ class Mob(tut_objects.TutorialObject):
attack_cmd = random.choice(("thrust", "pierce", "stab", "slash", "chop"))
self.execute_cmd("%s %s" % (attack_cmd, target))
if target.db.health is None:
# This is not an attackable target
logger.log_err(f"{self.key} found {target} had an `health` attribute of `None`.")
return
# analyze the current state
if target.db.health <= 0:
# we reduced the target to <= 0 health. Move them to the

View file

@ -22,7 +22,7 @@ WeaponRack
import random
from evennia import DefaultObject, DefaultExit, Command, CmdSet
from evennia.utils import search, delay
from evennia.utils import search, delay, dedent
from evennia.prototypes.spawner import spawn
# -------------------------------------------------------------
@ -647,7 +647,7 @@ class CrumblingWall(TutorialObject, DefaultExit):
"""called when the object is first created."""
super().at_object_creation()
self.aliases.add(["secret passage", "passage", "crack", "opening", "secret door"])
self.aliases.add(["secret passage", "passage", "crack", "opening", "secret"])
# starting root positions. H1/H2 are the horizontally hanging roots,
# V1/V2 the vertically hanging ones. Each can have three positions:
@ -688,6 +688,7 @@ class CrumblingWall(TutorialObject, DefaultExit):
# start a 45 second timer before closing again. We store the deferred so it can be
# killed in unittesting.
self.deferred = delay(45, self.reset)
return True
def _translate_position(self, root, ipos):
"""Translates the position into words"""
@ -740,7 +741,7 @@ class CrumblingWall(TutorialObject, DefaultExit):
"The wall is old and covered with roots that here and there have permeated the stone. "
"The roots (or whatever they are - some of them are covered in small nondescript flowers) "
"crisscross the wall, making it hard to clearly see its stony surface. Maybe you could "
"try to |wshift|n or |wmove|n them.\n"
"try to |wshift|n or |wmove|n them (like '|wshift red up|n').\n"
]
# display the root positions to help with the puzzle
for key, pos in self.db.root_pos.items():
@ -833,6 +834,7 @@ class CmdAttack(Command):
"stab",
"slash",
"chop",
"bash",
"parry",
"defend",
]
@ -875,7 +877,7 @@ class CmdAttack(Command):
tstring = "%s stabs at you with %s. " % (self.caller.key, self.obj.key)
ostring = "%s stabs at %s with %s. " % (self.caller.key, target.key, self.obj.key)
self.caller.db.combat_parry_mode = False
elif cmdstring in ("slash", "chop"):
elif cmdstring in ("slash", "chop", "bash"):
hit = float(self.obj.db.hit) # un modified due to slash
damage = self.obj.db.damage # un modified due to slash
string = "You slash with %s. " % self.obj.key
@ -1150,7 +1152,15 @@ class WeaponRack(TutorialObject):
self.db.rack_id = "weaponrack_1"
# these are prototype names from the prototype
# dictionary above.
self.db.get_weapon_msg = "You find |c%s|n."
self.db.get_weapon_msg = dedent(
"""
You find |c%s|n. While carrying this weapon, these actions are available:
|wstab/thrust/pierce <target>|n - poke at the enemy. More damage but harder to hit.
|wslash/chop/bash <target>|n - swipe at the enemy. Less damage but easier to hit.
|wdefend/parry|n - protect yourself and make yourself harder to hit.)
"""
).strip()
self.db.no_more_weapons_msg = "you find nothing else of use."
self.db.available_weapons = ["knife", "dagger", "sword", "club"]

View file

@ -69,11 +69,14 @@ class CmdTutorial(Command):
target = caller.search(self.args.strip())
if not target:
return
helptext = target.db.tutorial_info
helptext = target.db.tutorial_info or ""
if helptext:
caller.msg("|G%s|n" % helptext)
helptext = f" |G{helptext}|n"
else:
caller.msg("|RSorry, there is no tutorial help available here.|n")
helptext = " |RSorry, there is no tutorial help available here.|n"
helptext += "\n\n (Write 'give up' if you want to abandon your quest.)"
caller.msg(helptext)
# for the @detail command we inherit from MuxCommand, since
@ -201,6 +204,30 @@ class CmdTutorialLook(default_cmds.CmdLook):
return
class CmdTutorialGiveUp(default_cmds.MuxCommand):
"""
Give up the tutorial-world quest and return to Limbo, the start room of the
server.
"""
key = "give up"
aliases = ["abort"]
def func(self):
outro_room = OutroRoom.objects.all()
if outro_room:
outro_room = outro_room[0]
else:
self.caller.msg(
"That didn't work (seems like a bug). "
"Try to use the |wteleport|n command instead."
)
return
self.caller.move_to(outro_room)
class TutorialRoomCmdSet(CmdSet):
"""
Implements the simple tutorial cmdset. This will overload the look
@ -216,6 +243,7 @@ class TutorialRoomCmdSet(CmdSet):
self.add(CmdTutorial())
self.add(CmdTutorialSetDetail())
self.add(CmdTutorialLook())
self.add(CmdTutorialGiveUp())
class TutorialRoom(DefaultRoom):
@ -363,6 +391,34 @@ SUPERUSER_WARNING = (
# -------------------------------------------------------------
class CmdEvenniaIntro(Command):
"""
Start the Evennia intro wizard.
Usage:
intro
"""
key = "intro"
def func(self):
from .intro_menu import init_menu
# quell also superusers
if self.caller.account:
self.caller.account.execute_cmd("quell")
self.caller.msg("(Auto-quelling)")
init_menu(self.caller)
class CmdSetEvenniaIntro(CmdSet):
key = "Evennia Intro StartSet"
def at_cmdset_creation(self):
self.add(CmdEvenniaIntro())
class IntroRoom(TutorialRoom):
"""
Intro room
@ -381,6 +437,7 @@ class IntroRoom(TutorialRoom):
"This assigns the health Attribute to "
"the account."
)
self.cmdset.add(CmdSetEvenniaIntro, permanent=True)
def at_object_receive(self, character, source_location):
"""
@ -396,7 +453,12 @@ class IntroRoom(TutorialRoom):
if character.is_superuser:
string = "-" * 78 + SUPERUSER_WARNING + "-" * 78
character.msg("|r%s|n" % string.format(name=character.key, quell="|w@quell|r"))
character.msg("|r%s|n" % string.format(name=character.key, quell="|wquell|r"))
else:
# quell user
if character.account:
character.account.execute_cmd("quell")
character.msg("(Auto-quelling while in tutorial-world)")
# -------------------------------------------------------------
@ -617,7 +679,7 @@ class BridgeCmdSet(CmdSet):
"""This groups the bridge commands. We will store it on the room."""
key = "Bridge commands"
priority = 1 # this gives it precedence over the normal look/help commands.
priority = 2 # this gives it precedence over the normal look/help commands.
def at_cmdset_creation(self):
"""Called at first cmdset creation"""
@ -679,7 +741,7 @@ class BridgeRoom(WeatherRoom):
self.db.east_exit = "gate"
self.db.fall_exit = "cliffledge"
# add the cmdset on the room.
self.cmdset.add_default(BridgeCmdSet)
self.cmdset.add(BridgeCmdSet, permanent=True)
# since the default Character's at_look() will access the room's
# return_description (this skips the cmdset) when
# first entering it, we need to explicitly turn off the room
@ -1108,3 +1170,7 @@ class OutroRoom(TutorialRoom):
if obj.typeclass_path.startswith("evennia.contrib.tutorial_world"):
obj.delete()
character.tags.clear(category="tutorial_world")
def at_object_leave(self, character, destination):
if character.account:
character.account.execute_cmd("unquell")

View file

@ -98,7 +98,7 @@ class UnixCommandParser(argparse.ArgumentParser):
epilog (str): the epilog to show below options.
command (Command): the command calling the parser.
Kwargs:
Keyword Args:
Additional keyword arguments are directly sent to
`argparse.ArgumentParser`. You will find them on the
[parser's documentation](https://docs.python.org/2/library/argparse.html).

View file

@ -5,7 +5,7 @@ Commands describe the input the account can do to the game.
"""
from evennia import Command as BaseCommand
from evennia.commands.command import Command as BaseCommand
# from evennia import default_cmds

View file

@ -42,7 +42,7 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs):
quiet (bool, optional): If `True`, no messages will be echoed to caller
on errors.
Kwargs:
Keyword Args:
nofound_string (str): Replacement string to echo on a notfound error.
multimatch_string (str): Replacement string to echo on a multimatch error.

View file

@ -32,7 +32,6 @@ CONNECTION_SCREEN = """
If you need to create an account, type (without the <>'s):
|wcreate <username> <password>|n
If you have spaces in your username, enclose it in quotes.
Enter |whelp|n for more info. |wlook|n will re-show this screen.
|b==============================================================|n""".format(
settings.SERVERNAME, utils.get_evennia_version("short")

View file

@ -20,11 +20,11 @@ needed on the Evennia side.
MSSPTable = {
# Required fields
"NAME": "Evennia",
"NAME": "Mygame", # usually the same as SERVERNAME
# Generic
"CRAWL DELAY": "-1", # limit how often crawler updates the listing. -1 for no limit
"HOSTNAME": "", # current or new hostname
"PORT": ["4000"], # most important port should be *last* in list!
"CRAWL DELAY": "-1", # limit how often crawler may update the listing. -1 for no limit
"HOSTNAME": "", # telnet hostname
"PORT": ["4000"], # telnet port - most important port should be *last* in list!
"CODEBASE": "Evennia",
"CONTACT": "", # email for contacting the mud
"CREATED": "", # year MUD was created
@ -33,7 +33,7 @@ MSSPTable = {
"LANGUAGE": "", # name of language used, e.g. English
"LOCATION": "", # full English name of server country
"MINIMUM AGE": "0", # set to 0 if not applicable
"WEBSITE": "www.evennia.com",
"WEBSITE": "", # http:// address to your game website
# Categorisation
"FAMILY": "Custom", # evennia goes under 'Custom'
"GENRE": "None", # Adult, Fantasy, Historical, Horror, Modern, None, or Science Fiction
@ -41,10 +41,10 @@ MSSPTable = {
# Player versus Player, Player versus Environment,
# Roleplaying, Simulation, Social or Strategy
"GAMEPLAY": "",
"STATUS": "Open Beta", # Alpha, Closed Beta, Open Beta, Live
"STATUS": "Open Beta", # Allowed: Alpha, Closed Beta, Open Beta, Live
"GAMESYSTEM": "Custom", # D&D, d20 System, World of Darkness, etc. Use Custom if homebrew
# Subgenre: LASG, Medieval Fantasy, World War II, Frankenstein,
# Cyberpunk, Dragonlance, etc. Or None if not available.
# Cyberpunk, Dragonlance, etc. Or None if not applicable.
"SUBGENRE": "None",
# World
"AREAS": "0",
@ -56,7 +56,7 @@ MSSPTable = {
"LEVELS": "0", # use 0 if level-less
"RACES": "0", # use 0 if race-less
"SKILLS": "0", # use 0 if skill-less
# Protocols set to 1 or 0)
# Protocols set to 1 or 0; should usually not be changed)
"ANSI": "1",
"GMCP": "1",
"MSDP": "1",

View file

@ -26,3 +26,16 @@ def at_webserver_root_creation(web_root):
"""
return web_root
def at_webproxy_root_creation(web_root):
"""
This function can modify the portal proxy service.
Args:
web_root (evennia.server.webserver.Website): The Evennia
Website application. Use .putChild() to add new
subdomains that are Portal-accessible over TCP;
primarily for new protocol development, but suitable
for other shenanigans.
"""
return web_root

View file

@ -2,40 +2,56 @@
Prototypes
A prototype is a simple way to create individualized instances of a
given `Typeclass`. For example, you might have a Sword typeclass that
implements everything a Sword would need to do. The only difference
between different individual Swords would be their key, description
and some Attributes. The Prototype system allows to create a range of
such Swords with only minor variations. Prototypes can also inherit
and combine together to form entire hierarchies (such as giving all
Sabres and all Broadswords some common properties). Note that bigger
variations, such as custom commands or functionality belong in a
hierarchy of typeclasses instead.
given typeclass. It is dictionary with specific key names.
Example prototypes are read by the `@spawn` command but is also easily
available to use from code via `evennia.spawn` or `evennia.utils.spawner`.
Each prototype should be a dictionary. Use the same name as the
variable to refer to other prototypes.
For example, you might have a Sword typeclass that implements everything a
Sword would need to do. The only difference between different individual Swords
would be their key, description and some Attributes. The Prototype system
allows to create a range of such Swords with only minor variations. Prototypes
can also inherit and combine together to form entire hierarchies (such as
giving all Sabres and all Broadswords some common properties). Note that bigger
variations, such as custom commands or functionality belong in a hierarchy of
typeclasses instead.
A prototype can either be a dictionary placed into a global variable in a
python module (a 'module-prototype') or stored in the database as a dict on a
special Script (a db-prototype). The former can be created just by adding dicts
to modules Evennia looks at for prototypes, the latter is easiest created
in-game via the `olc` command/menu.
Prototypes are read and used to create new objects with the `spawn` command
or directly via `evennia.spawn` or the full path `evennia.prototypes.spawner.spawn`.
A prototype dictionary have the following keywords:
Possible keywords are:
prototype_parent - string pointing to parent prototype of this structure.
key - string, the main object identifier.
typeclass - string, if not set, will use `settings.BASE_OBJECT_TYPECLASS`.
location - this should be a valid object or #dbref.
home - valid object or #dbref.
destination - only valid for exits (object or dbref).
- `prototype_key` - the name of the prototype. This is required for db-prototypes,
for module-prototypes, the global variable name of the dict is used instead
- `prototype_parent` - string pointing to parent prototype if any. Prototype inherits
in a similar way as classes, with children overriding values in their partents.
- `key` - string, the main object identifier.
- `typeclass` - string, if not set, will use `settings.BASE_OBJECT_TYPECLASS`.
- `location` - this should be a valid object or #dbref.
- `home` - valid object or #dbref.
- `destination` - only valid for exits (object or #dbref).
- `permissions` - string or list of permission strings.
- `locks` - a lock-string to use for the spawned object.
- `aliases` - string or list of strings.
- `attrs` - Attributes, expressed as a list of tuples on the form `(attrname, value)`,
`(attrname, value, category)`, or `(attrname, value, category, locks)`. If using one
of the shorter forms, defaults are used for the rest.
- `tags` - Tags, as a list of tuples `(tag,)`, `(tag, category)` or `(tag, category, data)`.
- Any other keywords are interpreted as Attributes with no category or lock.
These will internally be added to `attrs` (eqivalent to `(attrname, value)`.
permissions - string or list of permission strings.
locks - a lock-string.
aliases - string or list of strings.
ndb_<name> - value of a nattribute (the "ndb_" part is ignored).
any other keywords are interpreted as Attributes and their values.
See the `@spawn` command and `evennia.utils.spawner` for more info.
See the `spawn` command and `evennia.prototypes.spawner.spawn` for more info.
"""
## example of module-based prototypes using
## the variable name as `prototype_key` and
## simple Attributes
# from random import randint
#
# GOBLIN = {
@ -43,7 +59,8 @@ See the `@spawn` command and `evennia.utils.spawner` for more info.
# "health": lambda: randint(20,30),
# "resists": ["cold", "poison"],
# "attacks": ["fists"],
# "weaknesses": ["fire", "light"]
# "weaknesses": ["fire", "light"],
# "tags": = [("greenskin", "monster"), ("humanoid", "monster")]
# }
#
# GOBLIN_WIZARD = {

View file

@ -131,7 +131,9 @@ class HelpEntryManager(TypedObjectManager):
for topic in topics:
topic.help_category = default_category
topic.save()
string = "Help database moved to category %s" % default_category
string = _("Help database moved to category {default_category}").format(
default_category=default_category
)
logger.log_info(string)
def search_help(self, ostring, help_category=None):

Binary file not shown.

View file

@ -0,0 +1,299 @@
msgid ""
msgstr ""
"Project-Id-Version: Evennia Russian Translation v0.1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-02-20 12:13+0000\n"
"PO-Revision-Date: 2020-04-19 18:32+0000\n"
"Last-Translator: 3eluk\n"
"Language-Team: Russian (Russia)\n"
"Language: ru-RU\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10 >= 2 && "
"n%10<=4 &&(n%100<10||n%100 >= 20)? 1 : 2);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Loco-Source-Locale: ru_RU\n"
"X-Generator: Loco https://localise.biz/\n"
"X-Loco-Parser: loco_parse_po"
#: accounts/accounts.py:440
msgid "Account being deleted."
msgstr "Аккаунт удаляется."
#: commands/cmdhandler.py:681
msgid "There were multiple matches."
msgstr "Здесь было несколько совпадений."
#: commands/cmdhandler.py:704
#, python-format
msgid "Command '%s' is not available."
msgstr "Команда '%s' недоступна."
#: commands/cmdhandler.py:709
#, python-format
msgid " Maybe you meant %s?"
msgstr "Возможно, вы имели ввиду %s?"
#: commands/cmdhandler.py:709
msgid "or"
msgstr "или"
#: commands/cmdhandler.py:711
msgid " Type \"help\" for help."
msgstr " Введи \"справка\" для получения помощи."
#: commands/cmdsethandler.py:89
msgid ""
"{traceback}\n"
"Error loading cmdset '{path}'\n"
"(Traceback was logged {timestamp})"
msgstr ""
#: commands/cmdsethandler.py:94
msgid ""
"Error loading cmdset: No cmdset class '{classname}' in '{path}'.\n"
"(Traceback was logged {timestamp})"
msgstr ""
#: commands/cmdsethandler.py:98
msgid ""
"{traceback}\n"
"SyntaxError encountered when loading cmdset '{path}'.\n"
"(Traceback was logged {timestamp})"
msgstr ""
#: commands/cmdsethandler.py:103
msgid ""
"{traceback}\n"
"Compile/Run error when loading cmdset '{path}'.\",\n"
"(Traceback was logged {timestamp})"
msgstr ""
#: commands/cmdsethandler.py:108
msgid ""
"\n"
"Error encountered for cmdset at path '{path}'.\n"
"Replacing with fallback '{fallback_path}'.\n"
msgstr ""
#: commands/cmdsethandler.py:114
msgid "Fallback path '{fallback_path}' failed to generate a cmdset."
msgstr ""
#: commands/cmdsethandler.py:182 commands/cmdsethandler.py:192
#, python-format
msgid ""
"\n"
"(Unsuccessfully tried '%s')."
msgstr ""
"\n"
"(Безуспешно пробую '%s')."
#: commands/cmdsethandler.py:311
msgid "custom {mergetype} on cmdset '{cmdset}'"
msgstr ""
#: commands/cmdsethandler.py:314
msgid " <Merged {mergelist} {mergetype}, prio {prio}>: {current}"
msgstr ""
#: commands/cmdsethandler.py:322
msgid ""
" <{key} ({mergetype}, prio {prio}, {permstring})>:\n"
" {keylist}"
msgstr ""
#: commands/cmdsethandler.py:426
msgid "Only CmdSets can be added to the cmdsethandler!"
msgstr ""
#: comms/channelhandler.py:100
msgid "Say what?"
msgstr "Сказать что?"
#: comms/channelhandler.py:105
#, python-format
msgid "Channel '%s' not found."
msgstr "Канал '%s' не обнаружен."
#: comms/channelhandler.py:108
#, python-format
msgid "You are not connected to channel '%s'."
msgstr "Ты не соединён с каналом '%s'."
#: comms/channelhandler.py:112
#, python-format
msgid "You are not permitted to send to channel '%s'."
msgstr "У тебя нет разрешения слать в канал '%s'."
#: comms/channelhandler.py:155
msgid " (channel)"
msgstr " (канал)"
#: locks/lockhandler.py:236
#, python-format
msgid "Lock: lock-function '%s' is not available."
msgstr ""
#: locks/lockhandler.py:249
#, python-format
msgid "Lock: definition '%s' has syntax errors."
msgstr ""
#: locks/lockhandler.py:253
#, python-format
msgid ""
"LockHandler on %(obj)s: access type '%(access_type)s' changed from '%(source)"
"s' to '%(goal)s' "
msgstr ""
#: locks/lockhandler.py:320
msgid "Lock: '{lockdef}' contains no colon (:)."
msgstr ""
#: locks/lockhandler.py:328
msgid "Lock: '{lockdef}' has no access_type (left-side of colon is empty)."
msgstr ""
#: locks/lockhandler.py:336
msgid "Lock: '{lockdef}' has mismatched parentheses."
msgstr ""
#: locks/lockhandler.py:343
msgid "Lock: '{lockdef}' has no valid lock functions."
msgstr ""
#: objects/objects.py:732
#, python-format
msgid "Couldn't perform move ('%s'). Contact an admin."
msgstr "Не удалось выполнить действие ('%s'). Свяжитесь с администратором."
#: objects/objects.py:742
msgid "The destination doesn't exist."
msgstr "Такой точки назначения нету."
#: objects/objects.py:833
#, python-format
msgid "Could not find default home '(#%d)'."
msgstr "Не обнаружен дом по умолчанию '(#%d)'."
#: objects/objects.py:849
msgid "Something went wrong! You are dumped into nowhere. Contact an admin."
msgstr ""
"Что-то пошло не так! Тебя выбрасывает в пустоту. Свяжитесь с администратором."
#: objects/objects.py:915
#, python-format
msgid "Your character %s has been destroyed."
msgstr "Ваш персонаж %s был уничтожен."
#: scripts/scripthandler.py:53
#, python-format
msgid ""
"\n"
" '%(key)s' (%(next_repeat)s/%(interval)s, %(repeats)s repeats): %(desc)s"
msgstr ""
#: scripts/scripts.py:205
#, python-format
msgid ""
"Script %(key)s(#%(dbid)s) of type '%(cname)s': at_repeat() error '%(err)s'."
msgstr ""
#: server/initial_setup.py:28
msgid ""
"\n"
"Welcome to your new |wEvennia|n-based game! Visit http://www.evennia.com if "
"you need\n"
"help, want to contribute, report issues or just join the community.\n"
"As Account #1 you can create a demo/tutorial area with |w@batchcommand "
"tutorial_world.build|n.\n"
" "
msgstr ""
"\n"
"Добро пожаловать в твою новую игру, основанную на |wEvennia|n! Посети http:"
"//www.evennia.com\n"
"если тебе нужна помощь, хочешь помочь, сообщить об ошибках, lили просто "
"присоединиться к сообществу.\n"
"Как Аккаунт №1, ты можешь создать зону для демонстрации/обучения командой "
"|w@batchcommand tutorial_world.build|n.\n"
" "
#: server/initial_setup.py:92
msgid "This is User #1."
msgstr "Это Пользователь №1."
#: server/initial_setup.py:105
msgid "Limbo"
msgstr "Лимб"
#: server/server.py:139
msgid "idle timeout exceeded"
msgstr "время бездействия превышено"
#: server/sessionhandler.py:386
msgid " ... Server restarted."
msgstr " ... Сервер перезапущен."
#: server/sessionhandler.py:606
msgid "Logged in from elsewhere. Disconnecting."
msgstr "Выполнено соединение в другом месте. Отключение."
#: server/sessionhandler.py:634
msgid "Idle timeout exceeded, disconnecting."
msgstr "Время бездействия превышено, отключение."
#: server/validators.py:50
#, python-format
msgid ""
"%s From a terminal client, you can also use a phrase of multiple words if "
"you enclose the password in double quotes."
msgstr ""
"%s Если вы используете терминал, вы можете использовать фразу из нескольких "
"слов если возьмёте пароль в двойные скобки."
#: utils/evmenu.py:192
msgid ""
"Menu node '{nodename}' is either not implemented or caused an error. Make "
"another choice."
msgstr ""
#: utils/evmenu.py:194
msgid "Error in menu node '{nodename}'."
msgstr ""
#: utils/evmenu.py:195
msgid "No description."
msgstr "Нет описания."
#: utils/evmenu.py:196
msgid "Commands: <menu option>, help, quit"
msgstr "Команды: <menu option>, справка, выход"
#: utils/evmenu.py:197
msgid "Commands: <menu option>, help"
msgstr "Команды: <menu option>, справка"
#: utils/evmenu.py:198
msgid "Commands: help, quit"
msgstr ""
#: utils/evmenu.py:199
msgid "Commands: help"
msgstr "Команды: справка"
#: utils/evmenu.py:200
msgid "Choose an option or try 'help'."
msgstr "Выберите опцию или введите \"справка\"."
#: utils/utils.py:1866
#, python-format
msgid "Could not find '%s'."
msgstr "Не обнаружено '%s'."
#: utils/utils.py:1873
#, python-format
msgid ""
"More than one match for '%s' (please narrow target):\n"
msgstr ""
"Больше одного подходящего варианта для '%s' (уточните цель):\n"

View file

@ -539,7 +539,11 @@ def objtag(accessing_obj, accessed_obj, *args, **kwargs):
Only true if accessed_obj has the specified tag and optional
category.
"""
return bool(accessed_obj.tags.get(*args))
if hasattr(accessed_obj, "obj"):
accessed_obj = accessed_obj.obj
tagkey = args[0] if args else None
category = args[1] if len(args) > 1 else None
return bool(accessed_obj.tags.get(tagkey, category=category))
def inside(accessing_obj, accessed_obj, *args, **kwargs):
@ -547,11 +551,44 @@ def inside(accessing_obj, accessed_obj, *args, **kwargs):
Usage:
inside()
Only true if accessing_obj is "inside" accessed_obj
True if accessing_obj is 'inside' accessing_obj. Note that this only checks
one level down. So if if the lock is on a room, you will pass but not your
inventory (since their location is you, not the locked object). If you
want also nested objects to pass the lock, use the `insiderecursive`
lockfunc.
"""
if hasattr(accessed_obj, "obj"):
accessed_obj = accessed_obj.obj
return accessing_obj.location == accessed_obj
def inside_rec(accessing_obj, accessed_obj, *args, **kwargs):
"""
Usage:
inside_rec()
True if accessing_obj is inside the accessed obj, at up to 10 levels
of recursion (so if this lock is on a room, then an object inside a box
in your inventory will also pass the lock).
"""
if hasattr(accessed_obj, "obj"):
accessed_obj = accessed_obj.obj
def _recursive_inside(obj, accessed_obj, lvl=1):
if obj.location:
if obj.location == accessed_obj:
return True
elif lvl >= 10:
# avoid infinite recursions
return False
else:
return _recursive_inside(obj.location, accessed_obj, lvl + 1)
return False
return _recursive_inside(accessing_obj, accessed_obj)
def holds(accessing_obj, accessed_obj, *args, **kwargs):
"""
Usage:
@ -604,7 +641,7 @@ def holds(accessing_obj, accessed_obj, *args, **kwargs):
if len(args) == 1:
# command is holds(dbref/key) - check if given objname/dbref is held by accessing_ob
return check_holds(args[0])
elif len(args=2):
elif len(args) > 1:
# command is holds(attrname, value) check if any held object has the given attribute and value
for obj in contents:
if obj.attributes.get(args[0]) == args[1]:

View file

@ -107,7 +107,7 @@ to any other identifier you can use.
import re
from django.conf import settings
from evennia.utils import logger, utils
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
__all__ = ("LockHandler", "LockException")
@ -236,7 +236,13 @@ class LockHandler(object):
elist.append(_("Lock: lock-function '%s' is not available.") % funcstring)
continue
args = list(arg.strip() for arg in rest.split(",") if arg and "=" not in arg)
kwargs = dict([arg.split("=", 1) for arg in rest.split(",") if arg and "=" in arg])
kwargs = dict(
[
(part.strip() for part in arg.split("=", 1))
for arg in rest.split(",")
if arg and "=" in arg
]
)
lock_funcs.append((func, args, kwargs))
evalstring = evalstring.replace(funcstring, "%s")
if len(lock_funcs) < nfuncs:
@ -246,7 +252,11 @@ class LockHandler(object):
evalstring = " ".join(_RE_OK.findall(evalstring))
eval(evalstring % tuple(True for func in funclist), {}, {})
except Exception:
elist.append(_("Lock: definition '%s' has syntax errors.") % raw_lockstring)
elist.append(
_("Lock: definition '{lock_string}' has syntax errors.").format(
lock_string=raw_lockstring
)
)
continue
if access_type in locks:
duplicates += 1

View file

@ -17,6 +17,7 @@ except ImportError:
from evennia import settings_default
from evennia.locks import lockfuncs
from evennia.utils.create import create_object
# ------------------------------------------------------------
# Lock testing
@ -174,11 +175,28 @@ class TestLockfuncs(EvenniaTest):
self.assertEqual(True, lockfuncs.objtag(None, self.obj2, "test2", "category1"))
self.assertEqual(False, lockfuncs.objtag(None, self.obj2, "test2"))
def test_traverse_taglock(self):
self.obj2.tags.add("test1", "category1")
self.exit.locks.add("traverse:tag(test1,category1)")
self.assertEqual(self.exit.access(self.obj2, "traverse"), True)
def test_traverse_taglock_fail(self):
self.obj2.tags.add("test1") # missing the category
self.exit.locks.add("traverse:tag(test1,category1)")
self.assertEqual(self.exit.access(self.obj2, "traverse"), False)
def test_inside_holds(self):
self.assertEqual(True, lockfuncs.inside(self.char1, self.room1))
self.assertEqual(False, lockfuncs.inside(self.char1, self.room2))
self.assertEqual(True, lockfuncs.holds(self.room1, self.char1))
self.assertEqual(False, lockfuncs.holds(self.room2, self.char1))
# test recursively
self.assertEqual(True, lockfuncs.inside_rec(self.char1, self.room1))
self.assertEqual(False, lockfuncs.inside_rec(self.char1, self.room2))
inventory_item = create_object(key="InsideTester", location=self.char1)
self.assertEqual(True, lockfuncs.inside_rec(inventory_item, self.room1))
self.assertEqual(False, lockfuncs.inside_rec(inventory_item, self.room2))
inventory_item.delete()
def test_has_account(self):
self.assertEqual(True, lockfuncs.has_account(self.char1, None))

View file

@ -8,6 +8,7 @@ from django.contrib import admin
from evennia.typeclasses.admin import AttributeInline, TagInline
from evennia.objects.models import ObjectDB
from django.contrib.admin.utils import flatten_fieldsets
from django.utils.translation import gettext as _
class ObjectAttributeInline(AttributeInline):

View file

@ -154,12 +154,12 @@ class ObjectDBManager(TypedObjectManager):
Args:
attribute_name (str): Attribute key to search for.
attribute_value (str): Attribute value to search for.
attribute_value (any): Attribute value to search for. This can also be database objects.
candidates (list, optional): Candidate objects to limit search to.
typeclasses (list, optional): Python pats to restrict matches with.
Returns:
matches (list): Objects fullfilling both the `attribute_name` and
matches (query): Objects fullfilling both the `attribute_name` and
`attribute_value` criterions.
Notes:
@ -175,31 +175,13 @@ class ObjectDBManager(TypedObjectManager):
)
type_restriction = typeclasses and Q(db_typeclass_path__in=make_iter(typeclasses)) or Q()
# This doesn't work if attribute_value is an object. Workaround below
if isinstance(attribute_value, (str, int, float, bool)):
return self.filter(
cand_restriction
& type_restriction
& Q(db_attributes__db_key=attribute_name, db_attributes__db_value=attribute_value)
).order_by("id")
else:
# We must loop for safety since the referenced lookup gives deepcopy error if attribute value is an object.
global _ATTR
if not _ATTR:
from evennia.typeclasses.models import Attribute as _ATTR
cands = list(
self.filter(
cand_restriction & type_restriction & Q(db_attributes__db_key=attribute_name)
)
)
results = [
attr.objectdb_set.all()
for attr in _ATTR.objects.filter(
objectdb__in=cands, db_value=attribute_value
).order_by("id")
]
return chain(*results)
results = self.filter(
cand_restriction
& type_restriction
& Q(db_attributes__db_key=attribute_name)
& Q(db_attributes__db_value=attribute_value)
).order_by("id")
return results
def get_objs_with_db_property(self, property_name, candidates=None):
"""
@ -273,7 +255,7 @@ class ObjectDBManager(TypedObjectManager):
to exclude from the match.
Returns:
contents (list): Matching contents, without excludeobj, if given.
contents (query): Matching contents, without excludeobj, if given.
"""
exclude_restriction = (
Q(pk__in=[_GA(obj, "id") for obj in make_iter(excludeobj)]) if excludeobj else Q()
@ -291,7 +273,7 @@ class ObjectDBManager(TypedObjectManager):
typeclasses (list): Only match objects with typeclasses having thess path strings.
Returns:
matches (list): A list of matches of length 0, 1 or more.
matches (query): A list of matches of length 0, 1 or more.
"""
if not isinstance(ostring, str):
if hasattr(ostring, "key"):
@ -484,19 +466,25 @@ class ObjectDBManager(TypedObjectManager):
# strips the number
match_number, searchdata = match.group("number"), match.group("name")
match_number = int(match_number) - 1
match_number = match_number if match_number >= 0 else None
if match_number is not None or not exact:
# run search again, with the exactness set by call
matches = _searcher(searchdata, candidates, typeclass, exact=exact)
# deal with result
if len(matches) > 1 and match_number is not None:
if len(matches) == 1 and match_number is not None and match_number != 0:
# this indicates trying to get a single match with a match-number
# targeting some higher-number match (like 2-box when there is only
# one box in the room). This leads to a no-match.
matches = []
elif len(matches) > 1 and match_number is not None:
# multiple matches, but a number was given to separate them
try:
if 0 <= match_number < len(matches):
# limit to one match
matches = [matches[match_number]]
except IndexError:
# match number not matching anything
pass
else:
# a number was given outside of range. This means a no-match.
matches = []
# return a list (possibly empty)
return matches
@ -575,8 +563,10 @@ class ObjectDBManager(TypedObjectManager):
return None
# copy over all attributes from old to new.
for attr in original_object.attributes.all():
new_object.attributes.add(attr.key, attr.value)
attrs = (
(a.key, a.value, a.category, a.lock_storage) for a in original_object.attributes.all()
)
new_object.attributes.batch_add(*attrs)
# copy over all cmdsets, if any
for icmdset, cmdset in enumerate(original_object.cmdset.all()):
@ -590,8 +580,10 @@ class ObjectDBManager(TypedObjectManager):
ScriptDB.objects.copy_script(script, new_obj=new_object)
# copy over all tags, if any
for tag in original_object.tags.get(return_tagobj=True, return_list=True):
new_object.tags.add(tag=tag.db_key, category=tag.db_category, data=tag.db_data)
tags = (
(t.db_key, t.db_category, t.db_data) for t in original_object.tags.all(return_objs=True)
)
new_object.tags.batch_add(*tags)
return new_object

View file

@ -24,7 +24,7 @@ from evennia.utils import logger
from evennia.utils.utils import make_iter, dbref, lazy_property
class ContentsHandler(object):
class ContentsHandler:
"""
Handles and caches the contents of an object to avoid excessive
lookups (this is done very often due to cmdhandler needing to look
@ -79,8 +79,9 @@ class ContentsHandler(object):
try:
return [self._idcache[pk] for pk in pks]
except KeyError:
# this means an actual failure of caching. Return real database match.
logger.log_err("contents cache failed for %s." % self.obj.key)
# this means the central instance_cache was totally flushed.
# Re-fetching from database will rebuild the necessary parts of the cache
# for next fetch.
return list(ObjectDB.objects.filter(db_location=self.obj))
def add(self, obj):

View file

@ -24,6 +24,7 @@ from evennia.utils import search
from evennia.utils import logger
from evennia.utils import ansi
from evennia.utils.utils import (
class_from_module,
variable_from_module,
lazy_property,
make_iter,
@ -31,7 +32,7 @@ from evennia.utils.utils import (
list_to_string,
to_str,
)
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
_INFLECT = inflect.engine()
_MULTISESSION_MODE = settings.MULTISESSION_MODE
@ -40,6 +41,7 @@ _ScriptDB = None
_SESSIONS = None
_AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit(".", 1))
_COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
# the sessid_max is based on the length of the db_sessid csv field (excluding commas)
_SESSID_MAX = 16 if _MULTISESSION_MODE in (1, 3) else 1
@ -206,7 +208,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
# lockstring of newly created objects, for easy overloading.
# Will be formatted with the appropriate attributes.
lockstring = "control:id({account_id}) or perm(Admin);" "delete:id({account_id}) or perm(Admin)"
lockstring = "control:id({account_id}) or perm(Admin);delete:id({account_id}) or perm(Admin)"
objects = ObjectManager()
@ -333,24 +335,29 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
Args:
count (int): Number of objects of this type
looker (Object): Onlooker. Not used by default.
Kwargs:
Keyword Args:
key (str): Optional key to pluralize, if given, use this instead of the object's key.
Returns:
singular (str): The singular form to display.
plural (str): The determined plural form of the key, including the count.
"""
plural_category = "plural_key"
key = kwargs.get("key", self.key)
key = ansi.ANSIString(key) # this is needed to allow inflection of colored names
plural = _INFLECT.plural(key, 2)
plural = "%s %s" % (_INFLECT.number_to_words(count, threshold=12), plural)
try:
plural = _INFLECT.plural(key, count)
plural = "{} {}".format(_INFLECT.number_to_words(count, threshold=12), plural)
except IndexError:
# this is raised by inflect if the input is not a proper noun
plural = key
singular = _INFLECT.an(key)
if not self.aliases.get(plural, category="plural_key"):
if not self.aliases.get(plural, category=plural_category):
# we need to wipe any old plurals/an/a in case key changed in the interrim
self.aliases.clear(category="plural_key")
self.aliases.add(plural, category="plural_key")
self.aliases.clear(category=plural_category)
self.aliases.add(plural, category=plural_category)
# save the singular form as an alias here too so we can display "an egg" and also
# look at 'an egg'.
self.aliases.add(singular, category="plural_key")
self.aliases.add(singular, category=plural_category)
return singular, plural
def search(
@ -384,9 +391,9 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
a global search.
- `me,self`: self-reference to this object
- `<num>-<string>` - can be used to differentiate
between multiple same-named matches
global_search (bool): Search all objects globally. This is overruled
by `location` keyword.
between multiple same-named matches. The exact form of this input
is given by `settings.SEARCH_MULTIMATCH_REGEX`.
global_search (bool): Search all objects globally. This overrules 'location' data.
use_nicks (bool): Use nickname-replace (nicktype "object") on `searchdata`.
typeclass (str or Typeclass, or list of either): Limit search only
to `Objects` with this typeclass. May be a list of typeclasses
@ -495,7 +502,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
)
if quiet:
return results
return list(results)
return _AT_SEARCH_RESULT(
results,
self,
@ -551,7 +558,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
session (Session, optional): Session to
return results to
Kwargs:
Keyword Args:
Other keyword arguments will be added to the found command
object instace as variables before it executes. This is
unused by default Evennia but may be used to set flags and
@ -597,7 +604,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
depends on the MULTISESSION_MODE.
options (dict, optional): Message-specific option-value
pairs. These will be applied at the protocol level.
Kwargs:
Keyword Args:
any (string or tuples): All kwarg keys not listed above
will be treated as send-command names and their arguments
(which can be a string or a tuple).
@ -648,7 +655,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
exclude (list, optional): A list of object not to call the
function on.
Kwargs:
Keyword Args:
Keyword arguments will be passed to the function for all objects.
"""
contents = self.contents
@ -680,7 +687,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
for every looker in contents that receives the
message. This allows for every object to potentially
get its own customized string.
Kwargs:
Keyword Args:
Keyword arguments will be passed on to `obj.msg()` for all
messaged objects.
@ -755,7 +762,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
(at_before/after_move etc) with quiet=True, this is as quiet a move
as can be done.
Kwargs:
Keyword Args:
Passed on to announce_move_to and announce_move_from hooks.
Returns:
@ -929,7 +936,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
key (str): Name of the new object.
account (Account): Account to attribute this object to.
Kwargs:
Keyword Args:
description (str): Brief description for this object.
ip (str): IP address of creator (for object auditing).
@ -1051,7 +1058,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
# See if we need to kick the account off.
for session in self.sessions.all():
session.msg(_("Your character %s has been destroyed.") % self.key)
session.msg(_("Your character {key} has been destroyed.").format(key=self.key))
# no need to disconnect, Account just jumps to OOC mode.
# sever the connection (important!)
if self.account:
@ -1095,7 +1102,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
no_superuser_bypass (bool, optional): If `True`, don't skip
lock check for superuser (be careful with this one).
Kwargs:
Keyword Args:
Passed on to the at_access hook along with the result of the access check.
"""
@ -1254,7 +1261,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
place to do it. This is called also if the object currently
have no cmdsets.
Kwargs:
Keyword Args:
caller (Session, Object or Account): The caller requesting
this cmdset.
@ -1356,7 +1363,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
accessing_obj (Object or Account): The entity trying to gain access.
access_type (str): The type of access that was requested.
Kwargs:
Keyword Args:
Not used by default, added for possible expandability in a
game.
@ -1607,7 +1614,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
text (str, optional): The message received.
from_obj (any, optional): The object sending the message.
Kwargs:
Keyword Args:
This includes any keywords sent to the `msg` method.
Returns:
@ -1629,7 +1636,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
text (str, optional): Text to send.
to_obj (any, optional): The object to send to.
Kwargs:
Keyword Args:
Keywords passed from msg()
Notes:
@ -1865,7 +1872,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
Args:
message (str): The suggested say/whisper text spoken by self.
Kwargs:
Keyword Args:
whisper (bool): If True, this is a whisper rather than
a say. This is sent by the whisper command by default.
Other verbal commands could use this hook in similar
@ -1905,7 +1912,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
(by default only used by whispers).
msg_receivers(str): Specific message to pass to the receiver(s). This will parsed
with the {receiver} placeholder replaced with the given receiver.
Kwargs:
Keyword Args:
whisper (bool): If this is a whisper rather than a say. Kwargs
can be used by other verbal commands in a similar way.
mapping (dict): Pass an additional mapping to the message.
@ -1936,12 +1943,14 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
# whisper mode
msg_type = "whisper"
msg_self = (
'{self} whisper to {all_receivers}, "{speech}"' if msg_self is True else msg_self
'{self} whisper to {all_receivers}, "|n{speech}|n"'
if msg_self is True
else msg_self
)
msg_receivers = msg_receivers or '{object} whispers: "{speech}"'
msg_receivers = msg_receivers or '{object} whispers: "|n{speech}|n"'
msg_location = None
else:
msg_self = '{self} say, "{speech}"' if msg_self is True else msg_self
msg_self = '{self} say, "|n{speech}|n"' if msg_self is True else msg_self
msg_location = msg_location or '{object} says, "{speech}"'
msg_receivers = msg_receivers or message
@ -2025,10 +2034,13 @@ class DefaultCharacter(DefaultObject):
# lockstring of newly created rooms, for easy overloading.
# Will be formatted with the appropriate attributes.
lockstring = "puppet:id({character_id}) or pid({account_id}) or perm(Developer) or pperm(Developer);delete:id({account_id}) or perm(Admin)"
lockstring = (
"puppet:id({character_id}) or pid({account_id}) or perm(Developer) or pperm(Developer);"
"delete:id({account_id}) or perm(Admin)"
)
@classmethod
def create(cls, key, account, **kwargs):
def create(cls, key, account=None, **kwargs):
"""
Creates a basic Character with default parameters, unless otherwise
specified or extended.
@ -2037,11 +2049,11 @@ class DefaultCharacter(DefaultObject):
Args:
key (str): Name of the new Character.
account (obj): Account to associate this Character with. Required as
an argument, but one can fake it out by supplying None-- it will
account (obj, optional): Account to associate this Character with.
If unset supplying None-- it will
change the default lockset and skip creator attribution.
Kwargs:
Keyword Args:
description (str): Brief description for this object.
ip (str): IP address of creator (for object auditing).
All other kwargs will be passed into the create_object call.
@ -2062,9 +2074,6 @@ class DefaultCharacter(DefaultObject):
# Set the supplied key as the name of the intended object
kwargs["key"] = key
# Get home for character
kwargs["home"] = ObjectDB.objects.get_id(kwargs.get("home", settings.DEFAULT_HOME))
# Get permissions
kwargs["permissions"] = kwargs.get("permissions", settings.PERMISSION_ACCOUNT_DEFAULT)
@ -2076,9 +2085,10 @@ class DefaultCharacter(DefaultObject):
try:
# Check to make sure account does not have too many chars
if len(account.characters) >= settings.MAX_NR_CHARACTERS:
errors.append("There are too many characters associated with this account.")
return obj, errors
if account:
if len(account.characters) >= settings.MAX_NR_CHARACTERS:
errors.append("There are too many characters associated with this account.")
return obj, errors
# Create the Character
obj = create.create_object(**kwargs)
@ -2247,7 +2257,7 @@ class DefaultRoom(DefaultObject):
)
@classmethod
def create(cls, key, account, **kwargs):
def create(cls, key, account=None, **kwargs):
"""
Creates a basic Room with default parameters, unless otherwise
specified or extended.
@ -2256,9 +2266,11 @@ class DefaultRoom(DefaultObject):
Args:
key (str): Name of the new Room.
account (obj): Account to associate this Room with.
account (obj, optional): Account to associate this Room with. If
given, it will be given specific control/edit permissions to this
object (along with normal Admin perms). If not given, default
Kwargs:
Keyword Args:
description (str): Brief description for this object.
ip (str): IP address of creator (for object auditing).
@ -2285,13 +2297,20 @@ class DefaultRoom(DefaultObject):
# Get description, if provided
description = kwargs.pop("description", "")
# get locks if provided
locks = kwargs.pop("locks", "")
try:
# Create the Room
obj = create.create_object(**kwargs)
# Set appropriate locks
lockstring = kwargs.get("locks", cls.lockstring.format(id=account.id))
obj.locks.add(lockstring)
# Add locks
if not locks and account:
locks = cls.lockstring.format(**{"id": account.id})
elif not locks and not account:
locks = cls.lockstring(**{"id": obj.id})
obj.locks.add(locks)
# Record creator id and creation IP
if ip:
@ -2327,8 +2346,7 @@ class DefaultRoom(DefaultObject):
# Default Exit command, used by the base exit object
#
class ExitCommand(command.Command):
class ExitCommand(_COMMAND_DEFAULT_CLASS):
"""
This is a command that simply cause the caller to traverse
the object it is attached to.
@ -2440,7 +2458,7 @@ class DefaultExit(DefaultObject):
# Command hooks
@classmethod
def create(cls, key, account, source, dest, **kwargs):
def create(cls, key, source, dest, account=None, **kwargs):
"""
Creates a basic Exit with default parameters, unless otherwise
specified or extended.
@ -2454,7 +2472,7 @@ class DefaultExit(DefaultObject):
source (Room): The room to create this exit in.
dest (Room): The room to which this exit should go.
Kwargs:
Keyword Args:
description (str): Brief description for this object.
ip (str): IP address of creator (for object auditing).
@ -2484,13 +2502,18 @@ class DefaultExit(DefaultObject):
description = kwargs.pop("description", "")
locks = kwargs.get("locks", "")
try:
# Create the Exit
obj = create.create_object(**kwargs)
# Set appropriate locks
lockstring = kwargs.get("locks", cls.lockstring.format(id=account.id))
obj.locks.add(lockstring)
if not locks and account:
locks = cls.lockstring.format(**{"id": account.id})
elif not locks and not account:
locks = cls.lockstring.format(**{"id": obj.id})
obj.locks.add(locks)
# Record creator id and creation IP
if ip:
@ -2524,10 +2547,10 @@ class DefaultExit(DefaultObject):
[
"puppet:false()", # would be weird to puppet an exit ...
"traverse:all()", # who can pass through exit by default
"get:false()",
"get:false()", # noone can pick up the exit
]
)
) # noone can pick up the exit
)
# an exit should have a destination (this is replaced at creation time)
if self.location:
@ -2541,7 +2564,7 @@ class DefaultExit(DefaultObject):
place to do it. This is called also if the object currently
has no cmdsets.
Kwargs:
Keyword Args:
force_init (bool): If `True`, force a re-build of the cmdset
(for example to update aliases).

View file

@ -9,23 +9,35 @@ class DefaultObjectTest(EvenniaTest):
def test_object_create(self):
description = "A home for a grouch."
home = self.room1.dbref
obj, errors = DefaultObject.create(
"trashcan", self.account, description=description, ip=self.ip
"trashcan", self.account, description=description, ip=self.ip, home=home
)
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc)
self.assertEqual(obj.db.creator_ip, self.ip)
self.assertEqual(obj.db_home, self.room1)
def test_character_create(self):
description = "A furry green monster, reeking of garbage."
home = self.room1.dbref
obj, errors = DefaultCharacter.create(
"oscar", self.account, description=description, ip=self.ip
"oscar", self.account, description=description, ip=self.ip, home=home
)
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc)
self.assertEqual(obj.db.creator_ip, self.ip)
self.assertEqual(obj.db_home, self.room1)
def test_character_create_noaccount(self):
obj, errors = DefaultCharacter.create("oscar", None, home=self.room1.dbref)
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
self.assertEqual(obj.db_home, self.room1)
def test_room_create(self):
description = "A dimly-lit alley behind the local Chinese restaurant."
@ -38,7 +50,7 @@ class DefaultObjectTest(EvenniaTest):
def test_exit_create(self):
description = "The steaming depths of the dumpster, ripe with refuse in various states of decomposition."
obj, errors = DefaultExit.create(
"in", self.account, self.room1, self.room2, description=description, ip=self.ip
"in", self.room1, self.room2, account=self.account, description=description, ip=self.ip
)
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
@ -101,3 +113,27 @@ class TestObjectManager(EvenniaTest):
self.assertEqual(list(query), [self.obj1])
query = ObjectDB.objects.get_objs_with_attr("NotFound", candidates=[self.char1, self.obj1])
self.assertFalse(query)
def test_copy_object(self):
"Test that all attributes and tags properly copy across objects"
# Add some tags
self.obj1.tags.add("plugh", category="adventure")
self.obj1.tags.add("xyzzy")
# Add some attributes
self.obj1.attributes.add("phrase", "plugh", category="adventure")
self.obj1.attributes.add("phrase", "xyzzy")
# Create object copy
obj2 = self.obj1.copy()
# Make sure each of the tags were replicated
self.assertTrue("plugh" in obj2.tags.all())
self.assertTrue("plugh" in obj2.tags.get(category="adventure"))
self.assertTrue("xyzzy" in obj2.tags.all())
# Make sure each of the attributes were replicated
self.assertEqual(obj2.attributes.get(key="phrase"), "xyzzy")
self.assertEqual(self.obj1.attributes.get(key="phrase", category="adventure"), "plugh")
self.assertEqual(obj2.attributes.get(key="phrase", category="adventure"), "plugh")

View file

@ -130,7 +130,7 @@ def _set_property(caller, raw_string, **kwargs):
caller (Object, Account): The user of the wizard.
raw_string (str): Input from user on given node - the new value to set.
Kwargs:
Keyword Args:
test_parse (bool): If set (default True), parse raw_string for protfuncs and obj-refs and
try to run result through literal_eval. The parser will be run in 'testing' mode and any
parsing errors will shown to the user. Note that this is just for testing, the original
@ -297,7 +297,7 @@ def _format_list_actions(*args, **kwargs):
Args:
actions (str): Available actions. The first letter of the action name will be assumed
to be a shortcut.
Kwargs:
Keyword Args:
prefix (str): Default prefix to use.
Returns:
string (str): Formatted footer for adding to the node text.
@ -1175,7 +1175,7 @@ def _add_attr(caller, attr_string, **kwargs):
attr = value
attr;category = value
attr;category;lockstring = value
Kwargs:
Keyword Args:
delete (str): If this is set, attr_string is
considered the name of the attribute to delete and
no further parsing happens.
@ -1235,7 +1235,7 @@ def _attr_select(caller, attrstr):
attr_tup = _get_tup_by_attrname(caller, attrname)
if attr_tup:
return "node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"}
return ("node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"})
else:
caller.msg("Attribute not found.")
return "node_attrs"
@ -1260,7 +1260,7 @@ def _attrs_actions(caller, raw_inp, **kwargs):
if action and attr_tup:
if action == "examine":
return "node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"}
return ("node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"})
elif action == "remove":
res = _add_attr(caller, attrname, delete=True)
caller.msg(res)
@ -1362,7 +1362,7 @@ def _add_tag(caller, tag_string, **kwargs):
tagname;category
tagname;category;data
Kwargs:
Keyword Args:
delete (str): If this is set, tag_string is considered
the name of the tag to delete.
@ -1439,7 +1439,7 @@ def _tags_actions(caller, raw_inp, **kwargs):
if tag_tup:
if action == "examine":
return "node_examine_entity", {"text": _display_tag(tag_tup), "back": "tags"}
return ("node_examine_entity", {"text": _display_tag(tag_tup), "back": "tags"})
elif action == "remove":
res = _add_tag(caller, tagname, delete=True)
caller.msg(res)
@ -1488,7 +1488,7 @@ def node_tags(caller):
as the |cprototype_key|n and with a category "{tag_category}". This allows the spawner to
optionally update previously spawned objects when their prototype changes.
""".format(
tag_category=protlib._PROTOTYPE_TAG_CATEGORY
tag_category=protlib.PROTOTYPE_TAG_CATEGORY
)
text = (text, helptext)
@ -1510,7 +1510,7 @@ def _locks_display(caller, lock):
def _lock_select(caller, lockstr):
return "node_examine_entity", {"text": _locks_display(caller, lockstr), "back": "locks"}
return ("node_examine_entity", {"text": _locks_display(caller, lockstr), "back": "locks"})
def _lock_add(caller, lock, **kwargs):
@ -1552,7 +1552,7 @@ def _locks_actions(caller, raw_inp, **kwargs):
if lock:
if action == "examine":
return "node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"}
return ("node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"})
elif action == "remove":
ret = _lock_add(caller, lock, delete=True)
caller.msg(ret)
@ -1645,7 +1645,10 @@ def _display_perm(caller, permission, only_hierarchy=False):
def _permission_select(caller, permission, **kwargs):
return "node_examine_entity", {"text": _display_perm(caller, permission), "back": "permissions"}
return (
"node_examine_entity",
{"text": _display_perm(caller, permission), "back": "permissions"},
)
def _add_perm(caller, perm, **kwargs):
@ -1908,7 +1911,7 @@ def _add_prototype_tag(caller, tag_string, **kwargs):
caller (Object): Caller of menu.
tag_string (str): Input from user - only tagname
Kwargs:
Keyword Args:
delete (str): If this is set, tag_string is considered
the name of the tag to delete.
@ -2051,7 +2054,7 @@ def _prototype_locks_actions(caller, raw_inp, **kwargs):
if lock:
if action == "examine":
return "node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"}
return ("node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"})
elif action == "remove":
ret = _prototype_lock_add(caller, lock.strip(), delete=True)
caller.msg(ret)
@ -2128,14 +2131,15 @@ def _keep_diff(caller, **kwargs):
tmp[path[-1]] = tuple(list(tmp[path[-1]][:-1]) + ["KEEP"])
def _format_diff_text_and_options(diff, **kwargs):
def _format_diff_text_and_options(diff, minimal=True, **kwargs):
"""
Reformat the diff in a way suitable for the olc menu.
Args:
diff (dict): A diff as produced by `prototype_diff`.
minimal (bool, optional): Don't show KEEPs.
Kwargs:
Keyword Args:
any (any): Forwarded into the generated options as arguments to the callable.
Returns:
@ -2147,12 +2151,15 @@ def _format_diff_text_and_options(diff, **kwargs):
def _visualize(obj, rootname, get_name=False):
if utils.is_iter(obj):
if not obj:
return str(obj)
if get_name:
return obj[0] if obj[0] else "<unset>"
if rootname == "attrs":
return "{} |W=|n {} |W(category:|n {}|W, locks:|n {}|W)|n".format(*obj)
elif rootname == "tags":
return "{} |W(category:|n {}|W)|n".format(obj[0], obj[1])
return "{}".format(obj)
def _parse_diffpart(diffpart, optnum, *args):
@ -2163,17 +2170,39 @@ def _format_diff_text_and_options(diff, **kwargs):
rootname = args[0]
old, new, instruction = diffpart
if instruction == "KEEP":
texts.append(" |gKEEP|W:|n {old}".format(old=_visualize(old, rootname)))
if not minimal:
texts.append(" |gKEEP|W:|n {old}".format(old=_visualize(old, rootname)))
else:
# instructions we should be able to revert by a menu choice
vold = _visualize(old, rootname)
vnew = _visualize(new, rootname)
vsep = "" if len(vold) < 78 else "\n"
vinst = "|rREMOVE|n" if instruction == "REMOVE" else "|y{}|n".format(instruction)
texts.append(
" |c[{num}] {inst}|W:|n {old} |W->|n{sep} {new}".format(
inst=vinst, num=optnum, old=vold, sep=vsep, new=vnew
if instruction == "ADD":
texts.append(
" |c[{optnum}] |yADD|n: {new}".format(
optnum=optnum, new=_visualize(new, rootname)
)
)
elif instruction == "REMOVE" and not new:
if rootname == "tags" and old[1] == protlib.PROTOTYPE_TAG_CATEGORY:
# special exception for the prototype-tag mechanism
# this is added post-spawn automatically and should
# not be listed as REMOVE.
return texts, options, optnum
texts.append(
" |c[{optnum}] |rREMOVE|n: {old}".format(
optnum=optnum, old=_visualize(old, rootname)
)
)
else:
vinst = "|y{}|n".format(instruction)
texts.append(
" |c[{num}] {inst}|W:|n {old} |W->|n{sep} {new}".format(
inst=vinst, num=optnum, old=vold, sep=vsep, new=vnew
)
)
)
options.append(
{
"key": str(optnum),
@ -2200,11 +2229,8 @@ def _format_diff_text_and_options(diff, **kwargs):
for root_key in sorted(diff):
diffpart = diff[root_key]
text, option, optnum = _parse_diffpart(diffpart, optnum, root_key)
heading = "- |w{}:|n ".format(root_key)
if root_key in ("attrs", "tags", "permissions"):
texts.append(heading)
elif text:
if text:
text = [heading + text[0]] + text[1:]
else:
text = [heading]
@ -2274,7 +2300,9 @@ def node_apply_diff(caller, **kwargs):
if not custom_location:
diff.pop("location", None)
txt, options = _format_diff_text_and_options(diff, objects=update_objects, base_obj=base_obj)
txt, options = _format_diff_text_and_options(
diff, objects=update_objects, base_obj=base_obj, prototype=prototype
)
if options:
text = [

View file

@ -9,9 +9,13 @@ import hashlib
import time
from ast import literal_eval
from django.conf import settings
from django.db.models import Q, Subquery
from django.core.paginator import Paginator
from evennia.scripts.scripts import DefaultScript
from evennia.objects.models import ObjectDB
from evennia.typeclasses.attributes import Attribute
from evennia.utils.create import create_script
from evennia.utils.evmore import EvMore
from evennia.utils.utils import (
all_from_module,
make_iter,
@ -52,10 +56,12 @@ _PROTOTYPE_RESERVED_KEYS = _PROTOTYPE_META_NAMES + (
"tags",
"attrs",
)
_PROTOTYPE_TAG_CATEGORY = "from_prototype"
PROTOTYPE_TAG_CATEGORY = "from_prototype"
_PROTOTYPE_TAG_META_CATEGORY = "db_prototype"
PROT_FUNCS = {}
_PROTOTYPE_FALLBACK_LOCK = "spawn:all();edit:all()"
class PermissionError(RuntimeError):
pass
@ -84,8 +90,19 @@ def homogenize_prototype(prototype, custom_keys=None):
homogenizations like adding missing prototype_keys and setting a default typeclass.
"""
if not prototype or not isinstance(prototype, dict):
return {}
reserved = _PROTOTYPE_RESERVED_KEYS + (custom_keys or ())
# correct cases of setting None for certain values
for protkey in prototype:
if prototype[protkey] is None:
if protkey in ("attrs", "tags", "prototype_tags"):
prototype[protkey] = []
elif protkey in ("prototype_key", "prototype_desc"):
prototype[protkey] = ""
attrs = list(prototype.get("attrs", [])) # break reference
tags = make_iter(prototype.get("tags", []))
homogenized_tags = []
@ -111,12 +128,14 @@ def homogenize_prototype(prototype, custom_keys=None):
# add required missing parts that had defaults before
if "prototype_key" not in prototype:
homogenized["prototype_key"] = homogenized.get(
"prototype_key",
# assign a random hash as key
homogenized["prototype_key"] = "prototype-{}".format(
hashlib.md5(bytes(str(time.time()), "utf-8")).hexdigest()[:7]
)
"prototype-{}".format(hashlib.md5(bytes(str(time.time()), "utf-8")).hexdigest()[:7]),
)
homogenized["prototype_tags"] = homogenized.get("prototype_tags", [])
homogenized["prototype_locks"] = homogenized.get("prototype_lock", _PROTOTYPE_FALLBACK_LOCK)
homogenized["prototype_desc"] = homogenized.get("prototype_desc", "")
if "typeclass" not in prototype and "prototype_parent" not in prototype:
homogenized["typeclass"] = settings.BASE_OBJECT_TYPECLASS
@ -125,33 +144,41 @@ def homogenize_prototype(prototype, custom_keys=None):
# module-based prototypes
for mod in settings.PROTOTYPE_MODULES:
# to remove a default prototype, override it with an empty dict.
# internally we store as (key, desc, locks, tags, prototype_dict)
prots = []
for variable_name, prot in all_from_module(mod).items():
if isinstance(prot, dict):
if "prototype_key" not in prot:
prot["prototype_key"] = variable_name.lower()
prots.append((prot["prototype_key"], homogenize_prototype(prot)))
# assign module path to each prototype_key for easy reference
_MODULE_PROTOTYPE_MODULES.update({prototype_key.lower(): mod for prototype_key, _ in prots})
# make sure the prototype contains all meta info
for prototype_key, prot in prots:
actual_prot_key = prot.get("prototype_key", prototype_key).lower()
prot.update(
{
"prototype_key": actual_prot_key,
"prototype_desc": prot["prototype_desc"] if "prototype_desc" in prot else mod,
"prototype_locks": (
prot["prototype_locks"]
if "prototype_locks" in prot
else "use:all();edit:false()"
),
"prototype_tags": list(set(make_iter(prot.get("prototype_tags", [])) + ["module"])),
}
)
_MODULE_PROTOTYPES[actual_prot_key] = prot
def load_module_prototypes():
"""
This is called by `evennia.__init__` as Evennia initializes. It's important
to do this late so as to not interfere with evennia initialization.
"""
for mod in settings.PROTOTYPE_MODULES:
# to remove a default prototype, override it with an empty dict.
# internally we store as (key, desc, locks, tags, prototype_dict)
prots = []
for variable_name, prot in all_from_module(mod).items():
if isinstance(prot, dict):
if "prototype_key" not in prot:
prot["prototype_key"] = variable_name.lower()
prots.append((prot["prototype_key"], homogenize_prototype(prot)))
# assign module path to each prototype_key for easy reference
_MODULE_PROTOTYPE_MODULES.update({prototype_key.lower(): mod for prototype_key, _ in prots})
# make sure the prototype contains all meta info
for prototype_key, prot in prots:
actual_prot_key = prot.get("prototype_key", prototype_key).lower()
prot.update(
{
"prototype_key": actual_prot_key,
"prototype_desc": prot["prototype_desc"] if "prototype_desc" in prot else mod,
"prototype_locks": (
prot["prototype_locks"]
if "prototype_locks" in prot
else "use:all();edit:false()"
),
"prototype_tags": list(
set(list(make_iter(prot.get("prototype_tags", []))) + ["module"])
),
}
)
_MODULE_PROTOTYPES[actual_prot_key] = prot
# Db-based prototypes
@ -223,14 +250,11 @@ def save_prototype(prototype):
)
# make sure meta properties are included with defaults
stored_prototype = DbPrototype.objects.filter(db_key=prototype_key)
prototype = stored_prototype[0].prototype if stored_prototype else {}
in_prototype["prototype_desc"] = in_prototype.get(
"prototype_desc", prototype.get("prototype_desc", "")
)
prototype_locks = in_prototype.get(
"prototype_locks", prototype.get("prototype_locks", "spawn:all();edit:perm(Admin)")
"prototype_locks", prototype.get("prototype_locks", _PROTOTYPE_FALLBACK_LOCK)
)
is_valid, err = validate_lockstring(prototype_locks)
if not is_valid:
@ -245,27 +269,26 @@ def save_prototype(prototype):
]
in_prototype["prototype_tags"] = prototype_tags
prototype.update(in_prototype)
stored_prototype = DbPrototype.objects.filter(db_key=prototype_key)
if stored_prototype:
# edit existing prototype
stored_prototype = stored_prototype[0]
stored_prototype.desc = prototype["prototype_desc"]
stored_prototype.desc = in_prototype["prototype_desc"]
if prototype_tags:
stored_prototype.tags.clear(category=_PROTOTYPE_TAG_CATEGORY)
stored_prototype.tags.batch_add(*prototype["prototype_tags"])
stored_prototype.locks.add(prototype["prototype_locks"])
stored_prototype.attributes.add("prototype", prototype)
stored_prototype.tags.clear(category=PROTOTYPE_TAG_CATEGORY)
stored_prototype.tags.batch_add(*in_prototype["prototype_tags"])
stored_prototype.locks.add(in_prototype["prototype_locks"])
stored_prototype.attributes.add("prototype", in_prototype)
else:
# create a new prototype
stored_prototype = create_script(
DbPrototype,
key=prototype_key,
desc=prototype["prototype_desc"],
desc=in_prototype["prototype_desc"],
persistent=True,
locks=prototype_locks,
tags=prototype["prototype_tags"],
attributes=[("prototype", prototype)],
tags=in_prototype["prototype_tags"],
attributes=[("prototype", in_prototype)],
)
return stored_prototype.prototype
@ -309,22 +332,28 @@ def delete_prototype(prototype_key, caller=None):
return True
def search_prototype(key=None, tags=None, require_single=False):
def search_prototype(key=None, tags=None, require_single=False, return_iterators=False):
"""
Find prototypes based on key and/or tags, or all prototypes.
Kwargs:
Keyword Args:
key (str): An exact or partial key to query for.
tags (str or list): Tag key or keys to query for. These
will always be applied with the 'db_protototype'
tag category.
require_single (bool): If set, raise KeyError if the result
was not found or if there are multiple matches.
return_iterators (bool): Optimized return for large numbers of db-prototypes.
If set, separate returns of module based prototypes and paginate
the db-prototype return.
Return:
matches (list): All found prototype dicts. Empty list if
matches (list): Default return, all found prototype dicts. Empty list if
no match was found. Note that if neither `key` nor `tags`
were given, *all* available prototypes will be returned.
list, queryset: If `return_iterators` are found, this is a list of
module-based prototypes followed by a *paginated* queryset of
db-prototypes.
Raises:
KeyError: If `require_single` is True and there are 0 or >1 matches.
@ -372,31 +401,37 @@ def search_prototype(key=None, tags=None, require_single=False):
tag_categories = ["db_prototype" for _ in tags]
db_matches = DbPrototype.objects.get_by_tag(tags, tag_categories)
else:
db_matches = DbPrototype.objects.all().order_by("id")
db_matches = DbPrototype.objects.all()
if key:
# exact or partial match on key
db_matches = (
db_matches.filter(db_key=key) or db_matches.filter(db_key__icontains=key)
).order_by("id")
# return prototype
db_prototypes = [dbprot.prototype for dbprot in db_matches]
exact_match = db_matches.filter(Q(db_key__iexact=key)).order_by("db_key")
if not exact_match:
# try with partial match instead
db_matches = db_matches.filter(Q(db_key__icontains=key)).order_by("db_key")
else:
db_matches = exact_match
matches = db_prototypes + module_prototypes
nmatches = len(matches)
if nmatches > 1 and key:
key = key.lower()
# avoid duplicates if an exact match exist between the two types
filter_matches = [
mta for mta in matches if mta.get("prototype_key") and mta["prototype_key"] == key
]
if filter_matches and len(filter_matches) < nmatches:
matches = filter_matches
# convert to prototype
db_ids = db_matches.values_list("id", flat=True)
db_matches = (
Attribute.objects.filter(scriptdb__pk__in=db_ids, db_key="prototype")
.values_list("db_value", flat=True)
.order_by("scriptdb__db_key")
)
if key and require_single:
nmodules = len(module_prototypes)
ndbprots = db_matches.count()
if nmodules + ndbprots != 1:
raise KeyError(f"Found {nmodules + ndbprots} matching prototypes.")
nmatches = len(matches)
if nmatches != 1 and require_single:
raise KeyError("Found {} matching prototypes.".format(nmatches))
return matches
if return_iterators:
# trying to get the entire set of prototypes - we must paginate
# the result instead of trying to fetch the entire set at once
return db_matches, module_prototypes
else:
# full fetch, no pagination (compatibility mode)
return list(db_matches) + module_prototypes
def search_objects_with_prototype(prototype_key):
@ -410,10 +445,113 @@ def search_objects_with_prototype(prototype_key):
matches (Queryset): All matching objects spawned from this prototype.
"""
return ObjectDB.objects.get_by_tag(key=prototype_key, category=_PROTOTYPE_TAG_CATEGORY)
return ObjectDB.objects.get_by_tag(key=prototype_key, category=PROTOTYPE_TAG_CATEGORY)
def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True):
class PrototypeEvMore(EvMore):
"""
Listing 1000+ prototypes can be very slow. So we customize EvMore to
display an EvTable per paginated page rather than to try creating an
EvTable for the entire dataset and then paginate it.
"""
def __init__(self, caller, *args, session=None, **kwargs):
"""Store some extra properties on the EvMore class"""
self.show_non_use = kwargs.pop("show_non_use", False)
self.show_non_edit = kwargs.pop("show_non_edit", False)
super().__init__(caller, *args, session=session, **kwargs)
def init_pages(self, inp):
"""
This will be initialized with a tuple (mod_prototype_list, paginated_db_query)
and we must handle these separately since they cannot be paginated in the same
way. We will build the prototypes so that the db-prototypes come first (they
are likely the most volatile), followed by the mod-prototypes.
"""
dbprot_query, modprot_list = inp
# set the number of entries per page to half the reported height of the screen
# to account for long descs etc
dbprot_paged = Paginator(dbprot_query, max(1, int(self.height / 2)))
# we separate the different types of data, so we track how many pages there are
# of each.
n_mod = len(modprot_list)
self._npages_mod = n_mod // self.height + (0 if n_mod % self.height == 0 else 1)
self._db_count = dbprot_paged.count
self._npages_db = dbprot_paged.num_pages if self._db_count > 0 else 0
# total number of pages
self._npages = self._npages_mod + self._npages_db
self._data = (dbprot_paged, modprot_list)
self._paginator = self.prototype_paginator
def prototype_paginator(self, pageno):
"""
The listing is separated in db/mod prototypes, so we need to figure out which
one to pick based on the page number. Also, pageno starts from 0.
"""
dbprot_pages, modprot_list = self._data
if self._db_count and pageno < self._npages_db:
return dbprot_pages.page(pageno + 1)
else:
# get the correct slice, adjusted for the db-prototypes
pageno = max(0, pageno - self._npages_db)
return modprot_list[pageno * self.height : pageno * self.height + self.height]
def page_formatter(self, page):
"""Input is a queryset page from django.Paginator"""
caller = self._caller
# get use-permissions of readonly attributes (edit is always False)
display_tuples = []
table = EvTable(
"|wKey|n",
"|wSpawn/Edit|n",
"|wTags|n",
"|wDesc|n",
border="tablecols",
crop=True,
width=self.width,
)
for prototype in page:
lock_use = caller.locks.check_lockstring(
caller, prototype.get("prototype_locks", ""), access_type="spawn", default=True
)
if not self.show_non_use and not lock_use:
continue
if prototype.get("prototype_key", "") in _MODULE_PROTOTYPES:
lock_edit = False
else:
lock_edit = caller.locks.check_lockstring(
caller, prototype.get("prototype_locks", ""), access_type="edit", default=True
)
if not self.show_non_edit and not lock_edit:
continue
ptags = []
for ptag in prototype.get("prototype_tags", []):
if is_iter(ptag):
if len(ptag) > 1:
ptags.append("{}".format(ptag[0]))
else:
ptags.append(ptag[0])
else:
ptags.append(str(ptag))
table.add_row(
prototype.get("prototype_key", "<unset>"),
"{}/{}".format("Y" if lock_use else "N", "Y" if lock_edit else "N"),
", ".join(list(set(ptags))),
prototype.get("prototype_desc", "<unset>"),
)
return str(table)
def list_prototypes(
caller, key=None, tags=None, show_non_use=False, show_non_edit=True, session=None
):
"""
Collate a list of found prototypes based on search criteria and access.
@ -423,65 +561,29 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed
tags (str or list, optional): Tag key or keys to query for.
show_non_use (bool, optional): Show also prototypes the caller may not use.
show_non_edit (bool, optional): Show also prototypes the caller may not edit.
session (Session, optional): If given, this is used for display formatting.
Returns:
table (EvTable or None): An EvTable representation of the prototypes. None
if no prototypes were found.
PrototypeEvMore: An EvMore subclass optimized for prototype listings.
None: If no matches were found. In this case the caller has already been notified.
"""
# this allows us to pass lists of empty strings
tags = [tag for tag in make_iter(tags) if tag]
# get prototypes for readonly and db-based prototypes
prototypes = search_prototype(key, tags)
dbprot_query, modprot_list = search_prototype(key, tags, return_iterators=True)
# get use-permissions of readonly attributes (edit is always False)
display_tuples = []
for prototype in sorted(prototypes, key=lambda d: d.get("prototype_key", "")):
lock_use = caller.locks.check_lockstring(
caller, prototype.get("prototype_locks", ""), access_type="spawn", default=True
)
if not show_non_use and not lock_use:
continue
if prototype.get("prototype_key", "") in _MODULE_PROTOTYPES:
lock_edit = False
else:
lock_edit = caller.locks.check_lockstring(
caller, prototype.get("prototype_locks", ""), access_type="edit", default=True
)
if not show_non_edit and not lock_edit:
continue
ptags = []
for ptag in prototype.get("prototype_tags", []):
if is_iter(ptag):
if len(ptag) > 1:
ptags.append("{} (category: {}".format(ptag[0], ptag[1]))
else:
ptags.append(ptag[0])
else:
ptags.append(str(ptag))
if not dbprot_query and not modprot_list:
caller.msg("No prototypes found.", session=session)
return None
display_tuples.append(
(
prototype.get("prototype_key", "<unset>"),
prototype.get("prototype_desc", "<unset>"),
"{}/{}".format("Y" if lock_use else "N", "Y" if lock_edit else "N"),
",".join(ptags),
)
)
if not display_tuples:
return ""
table = []
width = 78
for i in range(len(display_tuples[0])):
table.append([str(display_tuple[i]) for display_tuple in display_tuples])
table = EvTable("Key", "Desc", "Spawn/Edit", "Tags", table=table, crop=True, width=width)
table.reformat_column(0, width=22)
table.reformat_column(1, width=29)
table.reformat_column(2, width=11, align="c")
table.reformat_column(3, width=16)
return table
# get specific prototype (one value or exception)
return PrototypeEvMore(
caller,
(dbprot_query, modprot_list),
session=session,
show_non_use=show_non_use,
show_non_edit=show_non_edit,
)
def validate_prototype(
@ -558,7 +660,7 @@ def validate_prototype(
protparent = protparents.get(protstring)
if not protparent:
_flags["errors"].append(
"Prototype {}'s prototype_parent '{}' was not found.".format((protkey, protstring))
"Prototype {}'s prototype_parent '{}' was not found.".format(protkey, protstring)
)
if id(prototype) in _flags["visited"]:
_flags["errors"].append(
@ -634,7 +736,7 @@ def protfunc_parser(value, available_functions=None, testing=False, stacktrace=F
behave differently.
stacktrace (bool, optional): If set, print the stack parsing process of the protfunc-parser.
Kwargs:
Keyword Args:
session (Session): Passed to protfunc. Session of the entity spawning the prototype.
protototype (dict): Passed to protfunc. The dict this protfunc is a part of.
current_key(str): Passed to protfunc. The key in the prototype that will hold this value.
@ -713,15 +815,15 @@ def prototype_to_str(prototype):
prototype_desc=prototype.get("prototype_desc", "|wNone|n"),
prototype_parent=prototype.get("prototype_parent", "|wNone|n"),
)
key = prototype.get("key", "")
if key:
key = aliases = attrs = tags = locks = permissions = location = home = destination = ""
if "key" in prototype:
key = prototype["key"]
key = "|ckey:|n {key}".format(key=key)
aliases = prototype.get("aliases", "")
if aliases:
if "aliases" in prototype:
aliases = prototype["aliases"]
aliases = "|caliases:|n {aliases}".format(aliases=", ".join(aliases))
attrs = prototype.get("attrs", "")
if attrs:
if "attrs" in prototype:
attrs = prototype["attrs"]
out = []
for (attrkey, value, category, locks) in attrs:
locks = ", ".join(lock for lock in locks if lock)
@ -740,8 +842,8 @@ def prototype_to_str(prototype):
)
)
attrs = "|cattrs:|n\n {attrs}".format(attrs="\n ".join(out))
tags = prototype.get("tags", "")
if tags:
if "tags" in prototype:
tags = prototype["tags"]
out = []
for (tagkey, category, data) in tags:
out.append(
@ -750,20 +852,20 @@ def prototype_to_str(prototype):
)
)
tags = "|ctags:|n\n {tags}".format(tags=", ".join(out))
locks = prototype.get("locks", "")
if locks:
if "locks" in prototype:
locks = prototype["locks"]
locks = "|clocks:|n\n {locks}".format(locks=locks)
permissions = prototype.get("permissions", "")
if permissions:
if "permissions" in prototype:
permissions = prototype["permissions"]
permissions = "|cpermissions:|n {perms}".format(perms=", ".join(permissions))
location = prototype.get("location", "")
if location:
if "location" in prototype:
location = prototype["location"]
location = "|clocation:|n {location}".format(location=location)
home = prototype.get("home", "")
if home:
if "home" in prototype:
home = prototype["home"]
home = "|chome:|n {home}".format(home=home)
destination = prototype.get("destination", "")
if destination:
if "destination" in prototype:
destination = prototype["destination"]
destination = "|cdestination:|n {destination}".format(destination=destination)
body = "\n".join(
@ -833,7 +935,10 @@ def init_spawn_value(value, validator=None):
value = validator(value[0](*make_iter(args)))
else:
value = validator(value)
return protfunc_parser(value)
result = protfunc_parser(value)
if result != value:
return validator(result)
return result
def value_to_obj_or_any(value):

View file

@ -9,7 +9,7 @@ There main function is `spawn(*prototype)`, where the `prototype`
is a dictionary like this:
```python
from evennia.prototypes import prototypes
from evennia.prototypes import prototypes, spawner
prot = {
"prototype_key": "goblin",
@ -22,7 +22,10 @@ prot = {
"tags": ["mob", "evil", ('greenskin','mob')]
"attrs": [("weapon", "sword")]
}
# spawn something with the prototype
goblin = spawner.spawn(prot)
# make this into a db-saved prototype (optional)
prot = prototypes.create_prototype(prot)
```
@ -82,13 +85,13 @@ import random
{
"prototype_key": "goblin_wizard",
"prototype_parent": GOBLIN,
"prototype_parent": "GOBLIN",
"key": "goblin wizard",
"spells": ["fire ball", "lighting bolt"]
}
GOBLIN_ARCHER = {
"prototype_parent": GOBLIN,
"prototype_parent": "GOBLIN",
"key": "goblin archer",
"attack_skill": (random, (5, 10))"
"attacks": ["short bow"]
@ -104,7 +107,7 @@ ARCHWIZARD = {
GOBLIN_ARCHWIZARD = {
"key" : "goblin archwizard"
"prototype_parent": (GOBLIN_WIZARD, ARCHWIZARD),
"prototype_parent": ("GOBLIN_WIZARD", "ARCHWIZARD"),
}
```
@ -138,13 +141,14 @@ from django.conf import settings
import evennia
from evennia.objects.models import ObjectDB
from evennia.utils import logger
from evennia.utils.utils import make_iter, is_iter
from evennia.prototypes import prototypes as protlib
from evennia.prototypes.prototypes import (
value_to_obj,
value_to_obj_or_any,
init_spawn_value,
_PROTOTYPE_TAG_CATEGORY,
PROTOTYPE_TAG_CATEGORY,
)
@ -165,6 +169,19 @@ _PROTOTYPE_ROOT_NAMES = (
_NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES
class Unset:
"""
Helper class representing a non-set diff element.
"""
def __bool__(self):
return False
def __str__(self):
return "<Unset>"
# Helper
@ -268,7 +285,7 @@ def prototype_from_object(obj):
"""
# first, check if this object already has a prototype
prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True)
prot = obj.tags.get(category=PROTOTYPE_TAG_CATEGORY, return_list=True)
if prot:
prot = protlib.search_prototype(prot[0])
@ -322,9 +339,9 @@ def prototype_from_object(obj):
return prot
def prototype_diff(prototype1, prototype2, maxdepth=2):
def prototype_diff(prototype1, prototype2, maxdepth=2, homogenize=False, implicit_keep=False):
"""
A 'detailed' diff specifies differences down to individual sub-sectiions
A 'detailed' diff specifies differences down to individual sub-sections
of the prototype, like individual attributes, permissions etc. It is used
by the menu to allow a user to customize what should be kept.
@ -334,6 +351,12 @@ def prototype_diff(prototype1, prototype2, maxdepth=2):
maxdepth (int, optional): The maximum depth into the diff we go before treating the elements
of iterables as individual entities to compare. This is important since a single
attr/tag (for example) are represented by a tuple.
homogenize (bool, optional): Auto-homogenize both prototypes for the best comparison.
This is most useful for displaying.
implicit_keep (bool, optional): If set, the resulting diff will assume KEEP unless the new
prototype explicitly change them. That is, if a key exists in `prototype1` and
not in `prototype2`, it will not be REMOVEd but set to KEEP instead. This is particularly
useful for auto-generated prototypes when updating objects.
Returns:
diff (dict): A structure detailing how to convert prototype1 to prototype2. All
@ -344,12 +367,16 @@ def prototype_diff(prototype1, prototype2, maxdepth=2):
instruction can be one of "REMOVE", "ADD", "UPDATE" or "KEEP".
"""
_unset = Unset()
def _recursive_diff(old, new, depth=0):
old_type = type(old)
new_type = type(new)
if old_type == new_type and not (old or new):
# both old and new are unset, like [] or None
return (None, None, "KEEP")
if old_type != new_type:
if old and not new:
if depth < maxdepth and old_type == dict:
@ -358,6 +385,9 @@ def prototype_diff(prototype1, prototype2, maxdepth=2):
return {
part[0] if is_iter(part) else part: (part, None, "REMOVE") for part in old
}
if isinstance(new, Unset) and implicit_keep:
# the new does not define any change, use implicit-keep
return (old, None, "KEEP")
return (old, new, "REMOVE")
elif not old and new:
if depth < maxdepth and new_type == dict:
@ -371,7 +401,7 @@ def prototype_diff(prototype1, prototype2, maxdepth=2):
elif depth < maxdepth and new_type == dict:
all_keys = set(list(old.keys()) + list(new.keys()))
return {
key: _recursive_diff(old.get(key), new.get(key), depth=depth + 1)
key: _recursive_diff(old.get(key, _unset), new.get(key, _unset), depth=depth + 1)
for key in all_keys
}
elif depth < maxdepth and is_iter(new):
@ -379,7 +409,9 @@ def prototype_diff(prototype1, prototype2, maxdepth=2):
new_map = {part[0] if is_iter(part) else part: part for part in new}
all_keys = set(list(old_map.keys()) + list(new_map.keys()))
return {
key: _recursive_diff(old_map.get(key), new_map.get(key), depth=depth + 1)
key: _recursive_diff(
old_map.get(key, _unset), new_map.get(key, _unset), depth=depth + 1
)
for key in all_keys
}
elif old != new:
@ -387,7 +419,10 @@ def prototype_diff(prototype1, prototype2, maxdepth=2):
else:
return (old, new, "KEEP")
diff = _recursive_diff(prototype1, prototype2)
prot1 = protlib.homogenize_prototype(prototype1) if homogenize else prototype1
prot2 = protlib.homogenize_prototype(prototype2) if homogenize else prototype2
diff = _recursive_diff(prot1, prot2)
return diff
@ -460,7 +495,7 @@ def flatten_diff(diff):
return flat_diff
def prototype_diff_from_object(prototype, obj):
def prototype_diff_from_object(prototype, obj, implicit_keep=True):
"""
Get a simple diff for a prototype compared to an object which may or may not already have a
prototype (or has one but changed locally). For more complex migratations a manual diff may be
@ -474,6 +509,11 @@ def prototype_diff_from_object(prototype, obj):
diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...}
obj_prototype (dict): The prototype calculated for the given object. The diff is how to
convert this prototype into the new prototype.
implicit_keep (bool, optional): This is usually what one wants for object updating. When
set, this means the prototype diff will assume KEEP on differences
between the object-generated prototype and that which is not explicitly set in the
new prototype. This means e.g. that even though the object has a location, and the
prototype does not specify the location, it will not be unset.
Notes:
The `diff` is on the following form:
@ -486,11 +526,88 @@ def prototype_diff_from_object(prototype, obj):
"""
obj_prototype = prototype_from_object(obj)
diff = prototype_diff(obj_prototype, protlib.homogenize_prototype(prototype))
diff = prototype_diff(
obj_prototype, protlib.homogenize_prototype(prototype), implicit_keep=implicit_keep
)
return diff, obj_prototype
def batch_update_objects_with_prototype(prototype, diff=None, objects=None):
def format_diff(diff, minimal=True):
"""
Reformat a diff for presentation. This is a shortened version
of the olc _format_diff_text_and_options without the options.
Args:
diff (dict): A diff as produced by `prototype_diff`.
minimal (bool, optional): Only show changes (remove KEEPs)
Returns:
texts (str): The formatted text.
"""
valid_instructions = ("KEEP", "REMOVE", "ADD", "UPDATE")
def _visualize(obj, rootname, get_name=False):
if is_iter(obj):
if not obj:
return str(obj)
if get_name:
return obj[0] if obj[0] else "<unset>"
if rootname == "attrs":
return "{} |w=|n {} |w(category:|n |n{}|w, locks:|n {}|w)|n".format(*obj)
elif rootname == "tags":
return "{} |w(category:|n {}|w)|n".format(obj[0], obj[1])
return "{}".format(obj)
def _parse_diffpart(diffpart, rootname):
typ = type(diffpart)
texts = []
if typ == tuple and len(diffpart) == 3 and diffpart[2] in valid_instructions:
old, new, instruction = diffpart
if instruction == "KEEP":
if not minimal:
texts.append(" |gKEEP|n: {old}".format(old=_visualize(old, rootname)))
elif instruction == "ADD":
texts.append(" |yADD|n: {new}".format(new=_visualize(new, rootname)))
elif instruction == "REMOVE" and not new:
texts.append(" |rREMOVE|n: {old}".format(old=_visualize(old, rootname)))
else:
vold = _visualize(old, rootname)
vnew = _visualize(new, rootname)
vsep = "" if len(vold) < 78 else "\n"
vinst = " |rREMOVE|n" if instruction == "REMOVE" else "|y{}|n".format(instruction)
varrow = "|r->|n" if instruction == "REMOVE" else "|y->|n"
texts.append(
" {inst}|W:|n {old} |W{varrow}|n{sep} {new}".format(
inst=vinst, old=vold, varrow=varrow, sep=vsep, new=vnew
)
)
else:
for key in sorted(list(diffpart.keys())):
subdiffpart = diffpart[key]
text = _parse_diffpart(subdiffpart, rootname)
texts.extend(text)
return texts
texts = []
for root_key in sorted(diff):
diffpart = diff[root_key]
text = _parse_diffpart(diffpart, root_key)
if text or not minimal:
heading = "- |w{}:|n\n".format(root_key)
if text:
text = [heading + text[0]] + text[1:]
else:
text = [heading]
texts.extend(text)
return "\n ".join(line for line in texts if line)
def batch_update_objects_with_prototype(prototype, diff=None, objects=None, exact=False):
"""
Update existing objects with the latest version of the prototype.
@ -501,6 +618,12 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None):
If not given this will be constructed from the first object found.
objects (list, optional): List of objects to update. If not given, query for these
objects using the prototype's `prototype_key`.
exact (bool, optional): By default (`False`), keys not explicitly in the prototype will
not be applied to the object, but will be retained as-is. This is usually what is
expected - for example, one usually do not want to remove the object's location even
if it's not set in the prototype. With `exact=True`, all un-specified properties of the
objects will be removed if they exist. This will lead to a more accurate 1:1 correlation
between the object and the prototype but is usually impractical.
Returns:
changed (int): The number of objects that had changes applied to them.
@ -515,7 +638,7 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None):
prototype_key = new_prototype["prototype_key"]
if not objects:
objects = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY)
objects = ObjectDB.objects.get_by_tag(prototype_key, category=PROTOTYPE_TAG_CATEGORY)
if not objects:
return 0
@ -525,104 +648,117 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None):
# make sure the diff is flattened
diff = flatten_diff(diff)
changed = 0
for obj in objects:
do_save = False
old_prot_key = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True)
old_prot_key = obj.tags.get(category=PROTOTYPE_TAG_CATEGORY, return_list=True)
old_prot_key = old_prot_key[0] if old_prot_key else None
if prototype_key != old_prot_key:
obj.tags.clear(category=_PROTOTYPE_TAG_CATEGORY)
obj.tags.add(prototype_key, category=_PROTOTYPE_TAG_CATEGORY)
for key, directive in diff.items():
if directive in ("UPDATE", "REPLACE"):
try:
for key, directive in diff.items():
if key in _PROTOTYPE_META_NAMES:
# prototype meta keys are not stored on-object
if key not in new_prototype and not exact:
# we don't update the object if the prototype does not actually
# contain the key (the diff will report REMOVE but we ignore it
# since exact=False)
continue
val = new_prototype[key]
do_save = True
if directive in ("UPDATE", "REPLACE"):
if key == "key":
obj.db_key = init_spawn_value(val, str)
elif key == "typeclass":
obj.db_typeclass_path = init_spawn_value(val, str)
elif key == "location":
obj.db_location = init_spawn_value(val, value_to_obj)
elif key == "home":
obj.db_home = init_spawn_value(val, value_to_obj)
elif key == "destination":
obj.db_destination = init_spawn_value(val, value_to_obj)
elif key == "locks":
if directive == "REPLACE":
obj.locks.clear()
obj.locks.add(init_spawn_value(val, str))
elif key == "permissions":
if directive == "REPLACE":
obj.permissions.clear()
obj.permissions.batch_add(*(init_spawn_value(perm, str) for perm in val))
elif key == "aliases":
if directive == "REPLACE":
obj.aliases.clear()
obj.aliases.batch_add(*(init_spawn_value(alias, str) for alias in val))
elif key == "tags":
if directive == "REPLACE":
obj.tags.clear()
obj.tags.batch_add(
*(
(init_spawn_value(ttag, str), tcategory, tdata)
for ttag, tcategory, tdata in val
)
)
elif key == "attrs":
if directive == "REPLACE":
obj.attributes.clear()
obj.attributes.batch_add(
*(
(
init_spawn_value(akey, str),
init_spawn_value(aval, value_to_obj),
acategory,
alocks,
if key in _PROTOTYPE_META_NAMES:
# prototype meta keys are not stored on-object
continue
val = new_prototype[key]
do_save = True
if key == "key":
obj.db_key = init_spawn_value(val, str)
elif key == "typeclass":
obj.db_typeclass_path = init_spawn_value(val, str)
elif key == "location":
obj.db_location = init_spawn_value(val, value_to_obj)
elif key == "home":
obj.db_home = init_spawn_value(val, value_to_obj)
elif key == "destination":
obj.db_destination = init_spawn_value(val, value_to_obj)
elif key == "locks":
if directive == "REPLACE":
obj.locks.clear()
obj.locks.add(init_spawn_value(val, str))
elif key == "permissions":
if directive == "REPLACE":
obj.permissions.clear()
obj.permissions.batch_add(*(init_spawn_value(perm, str) for perm in val))
elif key == "aliases":
if directive == "REPLACE":
obj.aliases.clear()
obj.aliases.batch_add(*(init_spawn_value(alias, str) for alias in val))
elif key == "tags":
if directive == "REPLACE":
obj.tags.clear()
obj.tags.batch_add(
*(
(init_spawn_value(ttag, str), tcategory, tdata)
for ttag, tcategory, tdata in val
)
for akey, aval, acategory, alocks in val
)
)
elif key == "exec":
# we don't auto-rerun exec statements, it would be huge security risk!
pass
else:
obj.attributes.add(key, init_spawn_value(val, value_to_obj))
elif directive == "REMOVE":
do_save = True
if key == "key":
obj.db_key = ""
elif key == "typeclass":
# fall back to default
obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS
elif key == "location":
obj.db_location = None
elif key == "home":
obj.db_home = None
elif key == "destination":
obj.db_destination = None
elif key == "locks":
obj.locks.clear()
elif key == "permissions":
obj.permissions.clear()
elif key == "aliases":
obj.aliases.clear()
elif key == "tags":
obj.tags.clear()
elif key == "attrs":
obj.attributes.clear()
elif key == "exec":
# we don't auto-rerun exec statements, it would be huge security risk!
pass
else:
obj.attributes.remove(key)
elif key == "attrs":
if directive == "REPLACE":
obj.attributes.clear()
obj.attributes.batch_add(
*(
(
init_spawn_value(akey, str),
init_spawn_value(aval, value_to_obj),
acategory,
alocks,
)
for akey, aval, acategory, alocks in val
)
)
elif key == "exec":
# we don't auto-rerun exec statements, it would be huge security risk!
pass
else:
obj.attributes.add(key, init_spawn_value(val, value_to_obj))
elif directive == "REMOVE":
do_save = True
if key == "key":
obj.db_key = ""
elif key == "typeclass":
# fall back to default
obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS
elif key == "location":
obj.db_location = None
elif key == "home":
obj.db_home = None
elif key == "destination":
obj.db_destination = None
elif key == "locks":
obj.locks.clear()
elif key == "permissions":
obj.permissions.clear()
elif key == "aliases":
obj.aliases.clear()
elif key == "tags":
obj.tags.clear()
elif key == "attrs":
obj.attributes.clear()
elif key == "exec":
# we don't auto-rerun exec statements, it would be huge security risk!
pass
else:
obj.attributes.remove(key)
except Exception:
logger.log_trace(f"Failed to apply prototype '{prototype_key}' to {obj}.")
finally:
# we must always make sure to re-add the prototype tag
obj.tags.clear(category=PROTOTYPE_TAG_CATEGORY)
obj.tags.add(prototype_key, category=PROTOTYPE_TAG_CATEGORY)
if do_save:
changed += 1
obj.save()
@ -707,8 +843,8 @@ def spawn(*prototypes, **kwargs):
Args:
prototypes (str or dict): Each argument should either be a
prototype_key (will be used to find the prototype) or a full prototype
dictionary. These will be batched-spawned as one object each.
Kwargs:
dictionary. These will be batched-spawned as one object each.
Keyword Args:
prototype_modules (str or list): A python-path to a prototype
module, or a list of such paths. These will be used to build
the global protparents dictionary accessible by the input
@ -804,7 +940,7 @@ def spawn(*prototypes, **kwargs):
prototype_key = prototype.get("prototype_key", None)
if prototype_key:
# we make sure to add a tag identifying which prototype created this object
tags.append((prototype_key, _PROTOTYPE_TAG_CATEGORY))
tags.append((prototype_key, PROTOTYPE_TAG_CATEGORY))
val = prot.pop("exec", "")
execs = init_spawn_value(val, make_iter)

View file

@ -3,15 +3,17 @@ Unit tests for the prototypes and spawner
"""
from random import randint
from random import randint, sample
import mock
import uuid
from time import time
from anything import Something
from django.test.utils import override_settings
from evennia.utils.test_resources import EvenniaTest
from evennia.utils.tests.test_evmenu import TestEvMenu
from evennia.prototypes import spawner, prototypes as protlib
from evennia.prototypes import menus as olc_menus
from evennia.prototypes import protfuncs as protofuncs
from evennia.prototypes import protfuncs as protofuncs, spawner
from evennia.prototypes.prototypes import _PROTOTYPE_TAG_META_CATEGORY
@ -212,22 +214,21 @@ class TestUtils(EvenniaTest):
"puppet:pperm(Developer);tell:perm(Admin);view:all()",
"KEEP",
),
"prototype_tags": {},
"prototype_tags": (None, None, "KEEP"),
"attrs": {
"oldtest": (
("oldtest", "to_keep", None, ""),
("oldtest", "to_keep", None, ""),
"KEEP",
),
"test": (("test", "testval", None, ""), None, "REMOVE"),
"desc": (("desc", "changed desc", None, ""), None, "REMOVE"),
"fooattr": (None, ("fooattr", "fooattrval", None, ""), "ADD"),
"desc": (("desc", "changed desc", None, ""), None, "KEEP"),
"fooattr": (Something, ("fooattr", "fooattrval", None, ""), "ADD"),
"test": (
("test", "testval", None, ""),
("test", "testval_changed", None, ""),
"UPDATE",
),
"new": (None, ("new", "new_val", None, ""), "ADD"),
"new": (Something, ("new", "new_val", None, ""), "ADD"),
},
"key": ("Obj", "Obj", "KEEP"),
"typeclass": (
@ -246,7 +247,7 @@ class TestUtils(EvenniaTest):
spawner.flatten_diff(pdiff),
{
"aliases": "REMOVE",
"attrs": "REPLACE",
"attrs": "UPDATE",
"home": "KEEP",
"key": "KEEP",
"location": "KEEP",
@ -270,7 +271,9 @@ class TestUtils(EvenniaTest):
new_prot = spawner.prototype_from_object(self.obj1)
self.assertEqual(
{
"aliases": ["foo"],
"attrs": [
("desc", "changed desc", None, ""),
("fooattr", "fooattrval", None, ""),
("new", "new_val", None, ""),
("oldtest", "to_keep", None, ""),
@ -293,6 +296,7 @@ class TestUtils(EvenniaTest):
"view:all()",
]
),
"tags": [("footag", "foocategory", None), (Something, "from_prototype", None)],
"permissions": ["builder"],
"prototype_desc": "Built from Obj",
"prototype_key": Something,
@ -626,8 +630,8 @@ class TestPrototypeStorage(EvenniaTest):
# partial match
with mock.patch("evennia.prototypes.prototypes._MODULE_PROTOTYPES", {}):
self.assertEqual(list(protlib.search_prototype("prot")), [prot1b, prot2, prot3])
self.assertEqual(list(protlib.search_prototype(tags="foo1")), [prot1b, prot2, prot3])
self.assertCountEqual(protlib.search_prototype("prot"), [prot1b, prot2, prot3])
self.assertCountEqual(protlib.search_prototype(tags="foo1"), [prot1b, prot2, prot3])
self.assertTrue(str(str(protlib.list_prototypes(self.char1))))
@ -851,7 +855,7 @@ class TestMenuModule(EvenniaTest):
self.assertEqual(obj.typeclass_path, "evennia.objects.objects.DefaultObject")
self.assertEqual(
obj.tags.get(category=spawner._PROTOTYPE_TAG_CATEGORY), self.test_prot["prototype_key"]
obj.tags.get(category=spawner.PROTOTYPE_TAG_CATEGORY), self.test_prot["prototype_key"]
)
# update helpers
@ -912,24 +916,20 @@ class TestMenuModule(EvenniaTest):
texts, options = olc_menus._format_diff_text_and_options(obj_diff)
self.assertEqual(
"\n".join(texts),
"- |wattrs:|n \n"
" |gKEEP|W:|n desc |W=|n This is User #1. |W(category:|n None|W, locks:|n |W)|n\n"
" |c[1] |yADD|n|W:|n None |W->|n foo |W=|n bar |W(category:|n None|W, locks:|n |W)|n\n"
" |gKEEP|W:|n prelogout_location |W=|n #2 |W(category:|n None|W, locks:|n |W)|n\n"
"- |whome:|n |gKEEP|W:|n #2\n"
"- |wkey:|n |gKEEP|W:|n TestChar\n"
"- |wlocks:|n |gKEEP|W:|n boot:false();call:false();control:perm(Developer);delete:false();edit:false();examine:perm(Developer);get:false();msg:all();puppet:false();tell:perm(Admin);view:all()\n"
"- |wpermissions:|n \n"
" |gKEEP|W:|n developer\n"
"- |wprototype_desc:|n |c[2] |rREMOVE|n|W:|n Testobject build |W->|n None\n"
"- |wprototype_key:|n |gKEEP|W:|n TestDiffKey\n"
"- |wprototype_locks:|n |gKEEP|W:|n spawn:all();edit:all()\n"
"- |wprototype_tags:|n \n"
"- |wtags:|n \n"
" |c[3] |yADD|n|W:|n None |W->|n foo |W(category:|n None|W)|n\n"
"- |wtypeclass:|n |gKEEP|W:|n typeclasses.characters.Character",
"\n".join(txt.strip() for txt in texts),
"- |wattrs:|n |c[1] |yADD|n: foo |W=|n bar |W(category:|n None|W, locks:|n |W)|n"
"\n- |whome:|n"
"\n- |wkey:|n"
"\n- |wlocks:|n"
"\n- |wpermissions:|n"
"\n- |wprototype_desc:|n |c[2] |rREMOVE|n: Testobject build"
"\n- |wprototype_key:|n"
"\n- |wprototype_locks:|n"
"\n- |wprototype_tags:|n"
"\n- |wtags:|n |c[3] |yADD|n: foo |W(category:|n None|W)|n"
"\n- |wtypeclass:|n",
)
self.assertEqual(
options,
[
@ -1075,3 +1075,30 @@ class TestOLCMenu(TestEvMenu):
["node_index", "node_index", "node_index"],
],
]
class PrototypeCrashTest(EvenniaTest):
# increase this to 1000 for optimization testing
num_prototypes = 10
def create(self, num=None):
if not num:
num = self.num_prototypes
# print(f"Creating {num} additional prototypes...")
for x in range(num):
prot = {
"prototype_key": str(uuid.uuid4()),
"some_attributes": [str(uuid.uuid4()) for x in range(10)],
"prototype_tags": list(sample(["demo", "test", "stuff"], 2)),
}
protlib.save_prototype(prot)
def test_prototype_dos(self, *args, **kwargs):
num_prototypes = self.num_prototypes
for x in range(2):
self.create(num_prototypes)
# print("Attempting to list prototypes...")
# start_time = time()
self.char1.execute_cmd("spawn/list")
# print(f"Prototypes listed in {time()-start_time} seconds.")

View file

@ -73,7 +73,7 @@ class ScriptDBManager(TypedObjectManager):
Get all scripts in the database.
Args:
key (str, optional): Restrict result to only those
key (str or int, optional): Restrict result to only those
with matching key or dbref.
Returns:
@ -83,12 +83,9 @@ class ScriptDBManager(TypedObjectManager):
if key:
script = []
dbref = self.dbref(key)
if dbref or dbref == 0:
# return either [] or a valid list (never [None])
script = [res for res in [self.dbref_search(dbref)] if res]
if not script:
script = self.filter(db_key=key)
return script
if dbref:
return self.filter(id=dbref)
return self.filter(db_key__iexact=key.strip())
return self.all()
def delete_script(self, dbref):
@ -231,7 +228,7 @@ class ScriptDBManager(TypedObjectManager):
ostring = ostring.strip()
dbref = self.dbref(ostring)
if dbref or dbref == 0:
if dbref:
# this is a dbref, try to find the script directly
dbref_match = self.dbref_search(dbref)
if dbref_match and not (

View file

@ -93,7 +93,7 @@ class MonitorHandler(object):
def at_update(self, obj, fieldname):
"""
Called by the field as it saves.
Called by the field/attribute as it saves.
"""
to_delete = []
@ -125,7 +125,7 @@ class MonitorHandler(object):
persistent (bool, optional): If False, the monitor will survive
a server reload but not a cold restart. This is default.
Kwargs:
Keyword Args:
session (Session): If this keyword is given, the monitorhandler will
correctly analyze it and remove the monitor if after a reload/reboot
the session is no longer valid.

View file

@ -9,7 +9,7 @@ from evennia.scripts.models import ScriptDB
from evennia.utils import create
from evennia.utils import logger
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
class ScriptHandler(object):
@ -78,11 +78,20 @@ class ScriptHandler(object):
scriptclass, key=key, account=self.obj, autostart=autostart
)
else:
# the normal - adding to an Object
script = create.create_script(scriptclass, key=key, obj=self.obj, autostart=autostart)
# the normal - adding to an Object. We wait to autostart so we can differentiate
# a failing creation from a script that immediately starts/stops.
script = create.create_script(scriptclass, key=key, obj=self.obj, autostart=False)
if not script:
logger.log_err("Script %s could not be created and/or started." % scriptclass)
logger.log_err("Script %s failed to be created/started." % scriptclass)
return False
if autostart:
script.start()
if not script.id:
# this can happen if the script has repeats=1 or calls stop() in at_repeat.
logger.log_info(
"Script %s started and then immediately stopped; "
"it could probably be a normal function." % scriptclass
)
return True
def start(self, key):

View file

@ -8,7 +8,7 @@ ability to run timers.
from twisted.internet.defer import Deferred, maybeDeferred
from twisted.internet.task import LoopingCall
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from evennia.typeclasses.models import TypeclassBase
from evennia.scripts.models import ScriptDB
from evennia.scripts.manager import ScriptManager
@ -69,7 +69,7 @@ class ExtendedLoopingCall(LoopingCall):
steps if we want.
"""
assert not self.running, "Tried to start an already running " "ExtendedLoopingCall."
assert not self.running, "Tried to start an already running ExtendedLoopingCall."
if interval < 0:
raise ValueError("interval must be >= 0")
self.running = True
@ -107,7 +107,8 @@ class ExtendedLoopingCall(LoopingCall):
if self.start_delay:
self.start_delay = None
self.starttime = self.clock.seconds()
LoopingCall.__call__(self)
if self._deferred:
LoopingCall.__call__(self)
def force_repeat(self):
"""
@ -118,7 +119,7 @@ class ExtendedLoopingCall(LoopingCall):
running.
"""
assert self.running, "Tried to fire an ExtendedLoopingCall " "that was not running."
assert self.running, "Tried to fire an ExtendedLoopingCall that was not running."
self.call.cancel()
self.call = None
self.starttime = self.clock.seconds()
@ -135,11 +136,10 @@ class ExtendedLoopingCall(LoopingCall):
the task is not running.
"""
if self.running:
if self.running and self.interval > 0:
total_runtime = self.clock.seconds() - self.starttime
interval = self.start_delay or self.interval
return interval - (total_runtime % self.interval)
return None
class ScriptBase(ScriptDB, metaclass=TypeclassBase):
@ -162,9 +162,8 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
Start task runner.
"""
if self.ndb._task:
return
self.ndb._task = ExtendedLoopingCall(self._step_task)
if not self.ndb._task:
self.ndb._task = ExtendedLoopingCall(self._step_task)
if self.db._paused_time:
# the script was paused; restarting
@ -174,7 +173,8 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
)
del self.db._paused_time
del self.db._paused_repeats
else:
elif not self.ndb._task.running:
# starting script anew
self.ndb._task.start(self.db_interval, now=not self.db_start_delay)
@ -186,6 +186,7 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
task = self.ndb._task
if task and task.running:
task.stop()
self.ndb._task = None
def _step_errback(self, e):
"""
@ -208,6 +209,9 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
Step task runner. No try..except needed due to defer wrap.
"""
if not self.ndb._task:
# if there is no task, we have no business using this method
return
if not self.is_valid():
self.stop()
@ -217,10 +221,13 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
self.at_repeat()
# check repeats
callcount = self.ndb._task.callcount
maxcount = self.db_repeats
if maxcount > 0 and maxcount <= callcount:
self.stop()
if self.ndb._task:
# we need to check for the task in case stop() was called
# inside at_repeat() and it already went away.
callcount = self.ndb._task.callcount
maxcount = self.db_repeats
if maxcount > 0 and maxcount <= callcount:
self.stop()
def _step_task(self):
"""
@ -267,13 +274,13 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
self.db_key = cdict["key"]
updates.append("db_key")
if cdict.get("interval") and self.interval != cdict["interval"]:
self.db_interval = cdict["interval"]
self.db_interval = max(0, cdict["interval"])
updates.append("db_interval")
if cdict.get("start_delay") and self.start_delay != cdict["start_delay"]:
self.db_start_delay = cdict["start_delay"]
updates.append("db_start_delay")
if cdict.get("repeats") and self.repeats != cdict["repeats"]:
self.db_repeats = cdict["repeats"]
self.db_repeats = max(0, cdict["repeats"])
updates.append("db_repeats")
if cdict.get("persistent") and self.persistent != cdict["persistent"]:
self.db_persistent = cdict["persistent"]
@ -338,9 +345,9 @@ class DefaultScript(ScriptBase):
try:
obj = create.create_script(**kwargs)
except Exception as e:
except Exception:
logger.log_trace()
errors.append("The script '%s' encountered errors and could not be created." % key)
logger.log_err(e)
return obj, errors
@ -430,7 +437,7 @@ class DefaultScript(ScriptBase):
if self.is_active and not force_restart:
# The script is already running, but make sure we have a _task if
# this is after a cache flush
if not self.ndb._task and self.db_interval >= 0:
if not self.ndb._task and self.db_interval > 0:
self.ndb._task = ExtendedLoopingCall(self._step_task)
try:
start_delay, callcount = SCRIPT_FLUSH_TIMERS[self.id]
@ -562,11 +569,9 @@ class DefaultScript(ScriptBase):
Restarts an already existing/running Script from the
beginning, optionally using different settings. This will
first call the stop hooks, and then the start hooks again.
Args:
interval (int, optional): Allows for changing the interval
of the Script. Given in seconds. if `None`, will use the
already stored interval.
of the Script. Given in seconds. if `None`, will use the already stored interval.
repeats (int, optional): The number of repeats. If unset, will
use the previous setting.
start_delay (bool, optional): If we should wait `interval` seconds
@ -585,6 +590,7 @@ class DefaultScript(ScriptBase):
del self.db._paused_callcount
# set new flags and start over
if interval is not None:
interval = max(0, interval)
self.interval = interval
if repeats is not None:
self.repeats = repeats

View file

@ -100,7 +100,7 @@ class TaskHandler(object):
callback (function or instance method): the callback itself
any (any): any additional positional arguments to send to the callback
Kwargs:
Keyword Args:
persistent (bool, optional): persist the task (store it).
any (any): additional keyword arguments to send to the callback

View file

@ -73,16 +73,22 @@ from evennia.scripts.scripts import ExtendedLoopingCall
from evennia.server.models import ServerConfig
from evennia.utils.logger import log_trace, log_err
from evennia.utils.dbserialize import dbserialize, dbunserialize, pack_dbobj
from evennia.utils import variable_from_module
from evennia.utils import variable_from_module, inherits_from
_GA = object.__getattribute__
_SA = object.__setattr__
_ERROR_ADD_TICKER = """TickerHandler: Tried to add an invalid ticker:
{storekey}
{store_key}
Ticker was not added."""
_ERROR_ADD_TICKER_SUB_SECOND = """You are trying to add a ticker running faster
than once per second. This is not supported and also probably not useful:
Spamming messages to the user faster than once per second serves no purpose in
a text-game, and if you want to update some property, consider doing so
on-demand rather than using a ticker.
"""
class Ticker(object):
"""
@ -183,7 +189,7 @@ class Ticker(object):
store_key (str): Unique storage hash for this ticker subscription.
args (any, optional): Arguments to call the hook method with.
Kwargs:
Keyword Args:
_start_delay (int): If set, this will be
used to delay the start of the trigger instead of
`interval`.
@ -319,7 +325,6 @@ class TickerHandler(object):
callback (function or method): This is either a stand-alone
function or class method on a typeclassed entitye (that is,
an entity that can be saved to the database).
Returns:
ret (tuple): This is a tuple of the form `(obj, path, callfunc)`,
where `obj` is the database object the callback is defined on
@ -327,6 +332,8 @@ class TickerHandler(object):
the python-path to the stand-alone function (`None` if a method).
The `callfunc` is either the name of the method to call or the
callable function object itself.
Raises:
TypeError: If the callback is of an unsupported type.
"""
outobj, outpath, outcallfunc = None, None, None
@ -337,8 +344,17 @@ class TickerHandler(object):
elif inspect.isfunction(callback):
outpath = "%s.%s" % (callback.__module__, callback.__name__)
outcallfunc = callback
else:
raise TypeError(f"{callback} is not a method or function.")
else:
raise TypeError("%s is not a callable function or method." % callback)
raise TypeError(f"{callback} is not a callable function or method.")
if outobj and not inherits_from(outobj, "evennia.typeclasses.models.TypedObject"):
raise TypeError(
f"{callback} is a method on a normal object - it must "
"be either a method on a typeclass, or a stand-alone function."
)
return outobj, outpath, outcallfunc
def _store_key(self, obj, path, interval, callfunc, idstring="", persistent=True):
@ -349,7 +365,8 @@ class TickerHandler(object):
obj (Object, tuple or None): Subscribing object if any. If a tuple, this is
a packed_obj tuple from dbserialize.
path (str or None): Python-path to callable, if any.
interval (int): Ticker interval.
interval (int): Ticker interval. Floats will be converted to
nearest lower integer value.
callfunc (callable or str): This is either the callable function or
the name of the method to call. Note that the callable is never
stored in the key; that is uniquely identified with the python-path.
@ -368,6 +385,9 @@ class TickerHandler(object):
`idstring` and `persistent` are integers, strings and bools respectively.
"""
if interval < 1:
raise RuntimeError(_ERROR_ADD_TICKER_SUB_SECOND)
interval = int(interval)
persistent = bool(persistent)
packed_obj = pack_dbobj(obj)
@ -504,12 +524,6 @@ class TickerHandler(object):
when wanting to modify/remove the ticker later.
"""
if isinstance(callback, int):
raise RuntimeError(
"TICKER_HANDLER.add has changed: "
"the interval is now the first argument, callback the second."
)
obj, path, callfunc = self._get_callback(callback)
store_key = self._store_key(obj, path, interval, callfunc, idstring, persistent)
kwargs["_obj"] = obj

View file

@ -44,7 +44,7 @@ class ConnectionWizard(object):
resp = str(default)
if resp.lower() in options:
self.display(f" Selected '{resp}'.")
# self.display(f" Selected '{resp}'.")
desc, callback, kwargs = options[resp.lower()]
callback(self, **kwargs)
elif resp.lower() in ("quit", "q"):
@ -57,7 +57,7 @@ class ConnectionWizard(object):
"""
Ask a yes/no question inline.
Kwargs:
Keyword Args:
prompt (str): The prompt to ask.
default (str): "yes" or "no", used if pressing return.
Returns:
@ -83,7 +83,7 @@ class ConnectionWizard(object):
"""
Ask multiple-choice question, get response inline.
Kwargs:
Keyword Args:
prompt (str): Input prompt.
options (list): List of options. Will be indexable by sequence number 1...
default (int): The list index+1 of the default choice, if any
@ -114,7 +114,7 @@ class ConnectionWizard(object):
"""
Get arbitrary input inline.
Kwargs:
Keyword Args:
prompt (str): The display prompt.
default (str): If empty input, use this.
validator (callable): If given, the input will be passed
@ -161,8 +161,10 @@ class ConnectionWizard(object):
def node_start(wizard):
text = """
This wizard helps activate external networks with Evennia. It will create
a config that will be attached to the bottom of the game settings file.
This wizard helps to attach your Evennia server to external networks. It
will save to a file `server/conf/connection_settings.py` that will be
imported from the bottom of your game settings file. Once generated you can
also modify that file directly.
Make sure you have at least started the game once before continuing!
@ -174,11 +176,15 @@ def node_start(wizard):
node_game_index_start,
{},
),
# "2": ("Add MSSP information (for mud-list crawlers)",
# node_mssp_start, {}),
"2": ("MSSP setup (for mud-list crawlers)", node_mssp_start, {}),
# "3": ("Add Grapevine listing",
# node_grapevine_start, {}),
"2": ("View and Save created settings", node_view_and_apply_settings, {}),
# "4": ("Add IRC link",
# "node_irc_start", {}),
# "5" ("Add RSS feed",
# "node_rss_start", {}),
"s": ("View and (optionally) Save created settings", node_view_and_apply_settings, {}),
"q": ("Quit", lambda *args: sys.exit(), {}),
}
wizard.display(text)
@ -189,13 +195,13 @@ def node_start(wizard):
def node_game_index_start(wizard, **kwargs):
text = f"""
text = """
The Evennia game index (http://games.evennia.com) lists both active Evennia
games as well as games in various stages of development.
You can put up your game in the index also if you are not (yet) open for
players. If so, put 'None' for the connection details. Just tell us you
are out there and make us excited about your upcoming game!
players. If so, put 'None' for the connection details - you are just telling
us that you are out there, making us excited about your upcoming game!
Please check the listing online first to see that your exact game name is
not colliding with an existing game-name in the list (be nice!).
@ -222,9 +228,9 @@ def node_game_index_fields(wizard, status=None):
- pre-alpha: a game in its very early stages, mostly unfinished or unstarted
- alpha: a working concept, probably lots of bugs and incomplete features
- beta: a working game, but expect bugs and changing features
- launched: a full, working game that may still be expanded upon and improved later
- launched: a full, working game (that may still be expanded upon and improved later)
Current value:
Current value (return to keep):
{status_default}
"""
@ -233,6 +239,31 @@ def node_game_index_fields(wizard, status=None):
wizard.display(text)
wizard.game_index_listing["game_status"] = wizard.ask_choice("Select one: ", options)
# game name
name_default = settings.SERVERNAME
text = f"""
Your game's name should usually be the same as `settings.SERVERNAME`, but
you can set it to something else here if you want.
Current value:
{name_default}
"""
def name_validator(inp):
tmax = 80
tlen = len(inp)
if tlen > tmax:
print(f"The name must be shorter than {tmax} characters (was {tlen}).")
wizard.ask_continue()
return False
return True
wizard.display(text)
wizard.game_index_listing["game_name"] = wizard.ask_input(
default=name_default, validator=name_validator
)
# short desc
sdesc_default = wizard.game_index_listing.get("short_description", None)
@ -249,7 +280,7 @@ def node_game_index_fields(wizard, status=None):
def sdesc_validator(inp):
tmax = 255
tlen = len(inp)
if tlen > 255:
if tlen > tmax:
print(f"The short desc must be shorter than {tmax} characters (was {tlen}).")
wizard.ask_continue()
return False
@ -341,7 +372,7 @@ def node_game_index_fields(wizard, status=None):
Evennia is its own web server and runs your game's website. Enter the
URL of the website here, like http://yourwebsite.com, here.
Wtite 'None' if you are not offering a publicly visible website at this time.
Write 'None' if you are not offering a publicly visible website at this time.
Current value:
{website_default}
@ -359,7 +390,7 @@ def node_game_index_fields(wizard, status=None):
your specific URL here (when clicking this link you should launch into the
web client)
Wtite 'None' if you don't want to list a publicly accessible webclient.
Write 'None' if you don't want to list a publicly accessible webclient.
Current value:
{webclient_default}
@ -388,24 +419,26 @@ def node_game_index_fields(wizard, status=None):
def node_mssp_start(wizard):
mssp_module = mod_import(settings.MSSP_META_MODULE)
filename = mssp_module.__file__
mssp_module = mod_import(settings.MSSP_META_MODULE or "server.conf.mssp")
try:
filename = mssp_module.__file__
except AttributeError:
filename = "server/conf/mssp.py"
text = f"""
MSSP (Mud Server Status Protocol) allows online MUD-listing sites/crawlers
to continuously monitor your game and list information about it. Some of
this, like active player-count, Evennia will automatically add for you,
whereas many fields are manually added info about your game.
MSSP (Mud Server Status Protocol) has a vast amount of options so it must
be modified outside this wizard by directly editing its config file here:
'{filename}'
MSSP allows traditional online MUD-listing sites/crawlers to continuously
monitor your game and list information about it. Some of this, like active
player-count, Evennia will automatically add for you, whereas most fields
you need to set manually.
To use MSSP you should generally have a publicly open game that external
players can connect to. You also need to register at a MUD listing site to
tell them to list your game.
MSSP has a large number of configuration options and we found it was simply
a lot easier to set them in a file rather than using this wizard. So to
configure MSSP, edit the empty template listing found here:
'{filename}'
tell them to crawl your game.
"""
wizard.display(text)
@ -456,25 +489,32 @@ def node_view_and_apply_settings(wizard):
pp = pprint.PrettyPrinter(indent=4)
saves = False
game_index_txt = "No changes to save for Game Index."
if hasattr(wizard, "game_index_listing"):
if wizard.game_index_listing != settings.GAME_INDEX_LISTING:
game_index_txt = "No changes to save for Game Index."
else:
game_index_txt = "GAME_INDEX_ENABLED = True\n" "GAME_INDEX_LISTING = \\\n" + pp.pformat(
wizard.game_index_listing
)
saves = True
# game index
game_index_save_text = ""
game_index_listing = (
wizard.game_index_listing if hasattr(wizard, "game_index_listing") else None
)
if not game_index_listing and settings.GAME_INDEX_ENABLED:
game_index_listing = settings.GAME_INDEX_LISTING
if game_index_listing:
game_index_save_text = (
"GAME_INDEX_ENABLED = True\n"
"GAME_INDEX_LISTING = \\\n" + pp.pformat(game_index_listing)
)
saves = True
else:
game_index_save_text = "# No Game Index settings found."
text = game_index_txt
# potentially add other wizards in the future
text = game_index_save_text
wizard.display(f"Settings to save:\n\n{text}")
if saves:
if wizard.ask_yesno("Do you want to save these settings?") == "yes":
if wizard.ask_yesno("\nDo you want to save these settings?") == "yes":
wizard.save_output = text
_save_changes(wizard)
wizard.display("... saved!")
wizard.display("... saved!\nThe changes will apply after you reload your server.")
else:
wizard.display("... cancelled.")
wizard.ask_continue()

View file

@ -96,6 +96,12 @@ def check_errors(settings):
"must now be either None or a dict "
"specifying the properties of the channel to create."
)
if hasattr(settings, "CYCLE_LOGFILES"):
raise DeprecationWarning(
"settings.CYCLE_LOGFILES is unused and should be removed. "
"Use PORTAL/SERVER_LOG_DAY_ROTATION and PORTAL/SERVER_LOG_MAX_SIZE "
"to control log cycling."
)
def check_warnings(settings):
@ -109,3 +115,10 @@ def check_warnings(settings):
print(" [Devel: settings.IN_GAME_ERRORS is True. Turn off in production.]")
if settings.ALLOWED_HOSTS == ["*"]:
print(" [Devel: settings.ALLOWED_HOSTS set to '*' (all). Limit in production.]")
for dbentry in settings.DATABASES.values():
if "psycopg" in dbentry.get("ENGINE", ""):
print(
'Deprecation: postgresql_psycopg2 backend is deprecated". '
"Switch settings.DATABASES to use "
'"ENGINE": "django.db.backends.postgresql instead"'
)

View file

@ -27,6 +27,7 @@ from twisted.protocols import amp
from twisted.internet import reactor, endpoints
import django
from django.core.management import execute_from_command_line
from django.db.utils import ProgrammingError
# Signal processing
SIG = signal.SIGINT
@ -92,8 +93,8 @@ SRESET = chr(19) # shutdown server in reset mode
# requirements
PYTHON_MIN = "3.7"
TWISTED_MIN = "18.0.0"
DJANGO_MIN = "2.1"
DJANGO_REC = "2.2.5"
DJANGO_MIN = "2.2.5"
DJANGO_LT = "3.0"
try:
sys.path[1] = EVENNIA_ROOT
@ -373,8 +374,8 @@ ERROR_NOTWISTED = """
"""
ERROR_DJANGO_MIN = """
ERROR: Django {dversion} found. Evennia requires version {django_min}
or higher.
ERROR: Django {dversion} found. Evennia requires at least version {django_min} (but
no higher than {django_lt}).
If you are using a virtualenv, use the command `pip install --upgrade -e evennia` where
`evennia` is the folder to where you cloned the Evennia library. If not
@ -385,14 +386,9 @@ ERROR_DJANGO_MIN = """
any warnings and don't run `makemigrate` even if told to.
"""
NOTE_DJANGO_MIN = """
NOTE: Django {dversion} found. This will work, but Django {django_rec} is
recommended for production.
"""
NOTE_DJANGO_NEW = """
NOTE: Django {dversion} found. This is newer than Evennia's
recommended version ({django_rec}). It might work, but may be new
recommended version ({django_rec}). It might work, but is new
enough to not be fully tested yet. Report any issues.
"""
@ -1152,7 +1148,7 @@ def tail_log_files(filename1, filename2, start_lines1=20, start_lines2=20, rate=
# this happens if the file was cycled or manually deleted/edited.
print(
" ** Log file {filename} has cycled or been edited. "
"Restarting log. ".format(filehandle.name)
"Restarting log. ".format(filename=filehandle.name)
)
new_linecount = 0
old_linecount = 0
@ -1280,14 +1276,16 @@ def check_main_evennia_dependencies():
try:
dversion = ".".join(str(num) for num in django.VERSION if isinstance(num, int))
# only the main version (1.5, not 1.5.4.0)
dversion_main = ".".join(dversion.split(".")[:3])
dversion_main = ".".join(dversion.split(".")[:2])
if LooseVersion(dversion) < LooseVersion(DJANGO_MIN):
print(ERROR_DJANGO_MIN.format(dversion=dversion_main, django_min=DJANGO_MIN))
print(
ERROR_DJANGO_MIN.format(
dversion=dversion_main, django_min=DJANGO_MIN, django_lt=DJANGO_LT
)
)
error = True
elif LooseVersion(DJANGO_MIN) <= LooseVersion(dversion) < LooseVersion(DJANGO_REC):
print(NOTE_DJANGO_MIN.format(dversion=dversion_main, django_rec=DJANGO_REC))
elif LooseVersion(DJANGO_REC) < LooseVersion(dversion_main):
print(NOTE_DJANGO_NEW.format(dversion=dversion_main, django_rec=DJANGO_REC))
elif LooseVersion(DJANGO_LT) <= LooseVersion(dversion_main):
print(NOTE_DJANGO_NEW.format(dversion=dversion_main, django_rec=DJANGO_LT))
except ImportError:
print(ERROR_NODJANGO)
error = True
@ -1367,10 +1365,10 @@ def create_settings_file(init=True, secret_settings=False):
if not init:
# if not --init mode, settings file may already exist from before
if os.path.exists(settings_path):
inp = eval(input("%s already exists. Do you want to reset it? y/[N]> " % settings_path))
inp = input("%s already exists. Do you want to reset it? y/[N]> " % settings_path)
if not inp.lower() == "y":
print("Aborted.")
return
sys.exit()
else:
print("Reset the settings file.")
@ -1428,12 +1426,17 @@ def create_superuser():
django.core.management.call_command("createsuperuser", interactive=True)
def check_database():
def check_database(always_return=False):
"""
Check so the database exists.
Args:
always_return (bool, optional): If set, will always return True/False
also on critical errors. No output will be printed.
Returns:
exists (bool): `True` if the database exists, otherwise `False`.
"""
# Check so a database exists and is accessible
from django.db import connection
@ -1449,7 +1452,9 @@ def check_database():
try:
AccountDB.objects.get(id=1)
except django.db.utils.OperationalError as e:
except (django.db.utils.OperationalError, ProgrammingError) as e:
if always_return:
return False
print(ERROR_DATABASE.format(traceback=e))
sys.exit()
except AccountDB.DoesNotExist:
@ -1484,7 +1489,7 @@ def check_database():
new.save()
else:
create_superuser()
check_database()
check_database(always_return=always_return)
return True
@ -1634,7 +1639,7 @@ def error_check_python_modules(show_warnings=False):
python source files themselves). Best they fail already here
before we get any further.
Kwargs:
Keyword Args:
show_warnings (bool): If non-fatal warning messages should be shown.
"""
@ -2246,14 +2251,15 @@ def main():
# pass-through to django manager, but set things up first
check_db = False
need_gamedir = True
# some commands don't require the presence of a game directory to work
if option in ("makemessages", "compilemessages"):
need_gamedir = False
# handle special django commands
if option in ("runserver", "testserver"):
# we don't want the django test-webserver
print(WARNING_RUNSERVER)
if option in ("shell", "check"):
if option in ("makemessages", "compilemessages"):
# some commands don't require the presence of a game directory to work
need_gamedir = False
if option in ("shell", "check", "makemigrations", "createsuperuser"):
# some django commands requires the database to exist,
# or evennia._init to have run before they work right.
check_db = True
@ -2263,16 +2269,17 @@ def main():
init_game_directory(CURRENT_DIR, check_db=check_db, need_gamedir=need_gamedir)
if option in ("migrate", "makemigrations"):
# we have to launch migrate within the program to make sure migrations
# run within the scope of the launcher (otherwise missing a db will cause errors)
django.core.management.call_command(*([option] + unknown_args))
else:
# pass on to the core django manager - re-parse the entire input line
# but keep 'evennia' as the name instead of django-admin. This is
# an exit condition.
sys.argv[0] = re.sub(r"(-script\.pyw?|\.exe)?$", "", sys.argv[0])
sys.exit(execute_from_command_line())
if option == "migrate":
# we need to bypass some checks here for the first db creation
if not check_database(always_return=True):
django.core.management.call_command(*([option] + unknown_args))
sys.exit(0)
# pass on to the core django manager - re-parse the entire input line
# but keep 'evennia' as the name instead of django-admin. This is
# an exit condition.
sys.argv[0] = re.sub(r"(-script\.pyw?|\.exe)?$", "", sys.argv[0])
sys.exit(execute_from_command_line(sys.argv))
elif not args.tail_log:
# no input; print evennia info (don't pring if we're tailing log)

View file

@ -1,403 +0,0 @@
#!/usr/bin/env python
"""
This runner is controlled by the evennia launcher and should normally
not be launched directly. It manages the two main Evennia processes
(Server and Portal) and most importantly runs a passive, threaded loop
that makes sure to restart Server whenever it shuts down.
Since twistd does not allow for returning an optional exit code we
need to handle the current reload state for server and portal with
flag-files instead. The files, one each for server and portal either
contains True or False indicating if the process should be restarted
upon returning, or not. A process returning != 0 will always stop, no
matter the value of this file.
"""
import os
import sys
from argparse import ArgumentParser
from subprocess import Popen
import queue
import _thread
import evennia
try:
# check if launched with pypy
import __pypy__ as is_pypy
except ImportError:
is_pypy = False
SERVER = None
PORTAL = None
EVENNIA_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
EVENNIA_BIN = os.path.join(EVENNIA_ROOT, "bin")
EVENNIA_LIB = os.path.dirname(evennia.__file__)
SERVER_PY_FILE = os.path.join(EVENNIA_LIB, "server", "server.py")
PORTAL_PY_FILE = os.path.join(EVENNIA_LIB, "server", "portal", "portal.py")
GAMEDIR = None
SERVERDIR = "server"
SERVER_PIDFILE = None
PORTAL_PIDFILE = None
SERVER_RESTART = None
PORTAL_RESTART = None
SERVER_LOGFILE = None
PORTAL_LOGFILE = None
HTTP_LOGFILE = None
PPROFILER_LOGFILE = None
SPROFILER_LOGFILE = None
# messages
CMDLINE_HELP = """
This program manages the running Evennia processes. It is called
by evennia and should not be started manually. Its main task is to
sit and watch the Server and restart it whenever the user reloads.
The runner depends on four files for its operation, two PID files
and two RESTART files for Server and Portal respectively; these
are stored in the game's server/ directory.
"""
PROCESS_ERROR = """
{component} process error: {traceback}.
"""
PROCESS_IOERROR = """
{component} IOError: {traceback}
One possible explanation is that 'twistd' was not found.
"""
PROCESS_RESTART = "{component} restarting ..."
PROCESS_DOEXIT = "Deferring to external runner."
# Functions
def set_restart_mode(restart_file, flag="reload"):
"""
This sets a flag file for the restart mode.
"""
with open(restart_file, "w") as f:
f.write(str(flag))
def getenv():
"""
Get current environment and add PYTHONPATH
"""
sep = ";" if os.name == "nt" else ":"
env = os.environ.copy()
sys.path.insert(0, GAMEDIR)
env["PYTHONPATH"] = sep.join(sys.path)
return env
def get_restart_mode(restart_file):
"""
Parse the server/portal restart status
"""
if os.path.exists(restart_file):
with open(restart_file, "r") as f:
return f.read()
return "shutdown"
def get_pid(pidfile):
"""
Get the PID (Process ID) by trying to access
an PID file.
"""
pid = None
if os.path.exists(pidfile):
with open(pidfile, "r") as f:
pid = f.read()
return pid
def cycle_logfile(logfile):
"""
Rotate the old log files to <filename>.old
"""
logfile_old = logfile + ".old"
if os.path.exists(logfile):
# Cycle the old logfiles to *.old
if os.path.exists(logfile_old):
# E.g. Windows don't support rename-replace
os.remove(logfile_old)
os.rename(logfile, logfile_old)
# Start program management
def start_services(server_argv, portal_argv, doexit=False):
"""
This calls a threaded loop that launches the Portal and Server
and then restarts them when they finish.
"""
global SERVER, PORTAL
processes = queue.Queue()
def server_waiter(queue):
try:
rc = Popen(server_argv, env=getenv()).wait()
except Exception as e:
print(PROCESS_ERROR.format(component="Server", traceback=e))
return
# this signals the controller that the program finished
queue.put(("server_stopped", rc))
def portal_waiter(queue):
try:
rc = Popen(portal_argv, env=getenv()).wait()
except Exception as e:
print(PROCESS_ERROR.format(component="Portal", traceback=e))
return
# this signals the controller that the program finished
queue.put(("portal_stopped", rc))
if portal_argv:
try:
if not doexit and get_restart_mode(PORTAL_RESTART) == "True":
# start portal as interactive, reloadable thread
PORTAL = _thread.start_new_thread(portal_waiter, (processes,))
else:
# normal operation: start portal as a daemon;
# we don't care to monitor it for restart
PORTAL = Popen(portal_argv, env=getenv())
except IOError as e:
print(PROCESS_IOERROR.format(component="Portal", traceback=e))
return
try:
if server_argv:
if doexit:
SERVER = Popen(server_argv, env=getenv())
else:
# start server as a reloadable thread
SERVER = _thread.start_new_thread(server_waiter, (processes,))
except IOError as e:
print(PROCESS_IOERROR.format(component="Server", traceback=e))
return
if doexit:
# Exit immediately
return
# Reload loop
while True:
# this blocks until something is actually returned.
from twisted.internet.error import ReactorNotRunning
try:
try:
message, rc = processes.get()
except KeyboardInterrupt:
# this only matters in interactive mode
break
# restart only if process stopped cleanly
if (
message == "server_stopped"
and int(rc) == 0
and get_restart_mode(SERVER_RESTART) in ("True", "reload", "reset")
):
print(PROCESS_RESTART.format(component="Server"))
SERVER = _thread.start_new_thread(server_waiter, (processes,))
continue
# normally the portal is not reloaded since it's run as a daemon.
if (
message == "portal_stopped"
and int(rc) == 0
and get_restart_mode(PORTAL_RESTART) == "True"
):
print(PROCESS_RESTART.format(component="Portal"))
PORTAL = _thread.start_new_thread(portal_waiter, (processes,))
continue
break
except ReactorNotRunning:
break
def main():
"""
This handles the command line input of the runner, usually created by
the evennia launcher
"""
parser = ArgumentParser(description=CMDLINE_HELP)
parser.add_argument(
"--noserver",
action="store_true",
dest="noserver",
default=False,
help="Do not start Server process",
)
parser.add_argument(
"--noportal",
action="store_true",
dest="noportal",
default=False,
help="Do not start Portal process",
)
parser.add_argument(
"--logserver",
action="store_true",
dest="logserver",
default=False,
help="Log Server output to logfile",
)
parser.add_argument(
"--iserver",
action="store_true",
dest="iserver",
default=False,
help="Server in interactive mode",
)
parser.add_argument(
"--iportal",
action="store_true",
dest="iportal",
default=False,
help="Portal in interactive mode",
)
parser.add_argument(
"--pserver", action="store_true", dest="pserver", default=False, help="Profile Server"
)
parser.add_argument(
"--pportal", action="store_true", dest="pportal", default=False, help="Profile Portal"
)
parser.add_argument(
"--nologcycle",
action="store_false",
dest="nologcycle",
default=True,
help="Do not cycle log files",
)
parser.add_argument(
"--doexit",
action="store_true",
dest="doexit",
default=False,
help="Immediately exit after processes have started.",
)
parser.add_argument("gamedir", help="path to game dir")
parser.add_argument("twistdbinary", help="path to twistd binary")
parser.add_argument("slogfile", help="path to server log file")
parser.add_argument("plogfile", help="path to portal log file")
parser.add_argument("hlogfile", help="path to http log file")
args = parser.parse_args()
global GAMEDIR
global SERVER_LOGFILE, PORTAL_LOGFILE, HTTP_LOGFILE
global SERVER_PIDFILE, PORTAL_PIDFILE
global SERVER_RESTART, PORTAL_RESTART
global SPROFILER_LOGFILE, PPROFILER_LOGFILE
GAMEDIR = args.gamedir
sys.path.insert(1, os.path.join(GAMEDIR, SERVERDIR))
SERVER_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "server.pid")
PORTAL_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "portal.pid")
SERVER_RESTART = os.path.join(GAMEDIR, SERVERDIR, "server.restart")
PORTAL_RESTART = os.path.join(GAMEDIR, SERVERDIR, "portal.restart")
SERVER_LOGFILE = args.slogfile
PORTAL_LOGFILE = args.plogfile
HTTP_LOGFILE = args.hlogfile
TWISTED_BINARY = args.twistdbinary
SPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "server.prof")
PPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "portal.prof")
# set up default project calls
server_argv = [
TWISTED_BINARY,
"--nodaemon",
"--logfile=%s" % SERVER_LOGFILE,
"--pidfile=%s" % SERVER_PIDFILE,
"--python=%s" % SERVER_PY_FILE,
]
portal_argv = [
TWISTED_BINARY,
"--logfile=%s" % PORTAL_LOGFILE,
"--pidfile=%s" % PORTAL_PIDFILE,
"--python=%s" % PORTAL_PY_FILE,
]
# Profiling settings (read file from python shell e.g with
# p = pstats.Stats('server.prof')
pserver_argv = ["--savestats", "--profiler=cprofile", "--profile=%s" % SPROFILER_LOGFILE]
pportal_argv = ["--savestats", "--profiler=cprofile", "--profile=%s" % PPROFILER_LOGFILE]
# Server
pid = get_pid(SERVER_PIDFILE)
if pid and not args.noserver:
print(
"\nEvennia Server is already running as process %(pid)s. Not restarted." % {"pid": pid}
)
args.noserver = True
if args.noserver:
server_argv = None
else:
set_restart_mode(SERVER_RESTART, "shutdown")
if not args.logserver:
# don't log to server logfile
del server_argv[2]
print("\nStarting Evennia Server (output to stdout).")
else:
if not args.nologcycle:
cycle_logfile(SERVER_LOGFILE)
print("\nStarting Evennia Server (output to server logfile).")
if args.pserver:
server_argv.extend(pserver_argv)
print("\nRunning Evennia Server under cProfile.")
# Portal
pid = get_pid(PORTAL_PIDFILE)
if pid and not args.noportal:
print(
"\nEvennia Portal is already running as process %(pid)s. Not restarted." % {"pid": pid}
)
args.noportal = True
if args.noportal:
portal_argv = None
else:
if args.iportal:
# make portal interactive
portal_argv[1] = "--nodaemon"
set_restart_mode(PORTAL_RESTART, True)
print("\nStarting Evennia Portal in non-Daemon mode (output to stdout).")
else:
if not args.nologcycle:
cycle_logfile(PORTAL_LOGFILE)
cycle_logfile(HTTP_LOGFILE)
set_restart_mode(PORTAL_RESTART, False)
print("\nStarting Evennia Portal in Daemon mode (output to portal logfile).")
if args.pportal:
portal_argv.extend(pportal_argv)
print("\nRunning Evennia Portal under cProfile.")
if args.doexit:
print(PROCESS_DOEXIT)
# Windows fixes (Windows don't support pidfiles natively)
if os.name == "nt":
if server_argv:
del server_argv[-2]
if portal_argv:
del portal_argv[-2]
# Start processes
start_services(server_argv, portal_argv, doexit=args.doexit)
if __name__ == "__main__":
main()

View file

@ -1,8 +1,8 @@
# Evennia Game Index Client
Greg Taylor 2016
Greg Taylor 2016, Griatch 2020
This contrib features a client for the [Evennia Game Index]
This is a client for the [Evennia Game Index]
(http://evennia-game-index.appspot.com/), a listing of games built on
Evennia. By listing your game on the index, you make it easy for other
people in the community to discover your creation.
@ -14,74 +14,24 @@ on remedying this.*
## Listing your Game
To list your game, you'll need to enable the Evennia Game Index client.
Start by `cd`'ing to your game directory. From there, open up
`server/conf/server_services_plugins.py`. It might look something like this
if you don't have any other optional add-ons enabled:
To list your game, go to your game dir and run
```python
"""
Server plugin services
evennia connections
This plugin module can define user-created services for the Server to
start.
Follow the prompts to add details to the listing. Use `evennia reload`. In your log (visible with `evennia --log`
you should see a note that info has been sent to the game index.
This module must handle all imports and setups required to start a
twisted service (see examples in evennia.server.server). It must also
contain a function start_plugin_services(application). Evennia will
call this function with the main Server application (so your services
can be added to it). The function should not return anything. Plugin
services are started last in the Server startup process.
"""
## Detailed settings
def start_plugin_services(server):
"""
This hook is called by Evennia, last in the Server startup process.
server - a reference to the main server application.
"""
pass
```
To enable the client, import `EvenniaGameIndexService` and fire it up after the
Evennia server has finished starting:
```python
"""
Server plugin services
This plugin module can define user-created services for the Server to
start.
This module must handle all imports and setups required to start a
twisted service (see examples in evennia.server.server). It must also
contain a function start_plugin_services(application). Evennia will
call this function with the main Server application (so your services
can be added to it). The function should not return anything. Plugin
services are started last in the Server startup process.
"""
from evennia.contrib.egi_client import EvenniaGameIndexService
def start_plugin_services(server):
"""
This hook is called by Evennia, last in the Server startup process.
server - a reference to the main server application.
"""
egi_service = EvenniaGameIndexService()
server.services.addService(egi_service)
```
Next, configure your game listing by opening up `server/conf/settings.py` and
If you don't want to use the wizard you can configure your game listing by opening up `server/conf/settings.py` and
using the following as a starting point:
```python
######################################################################
# Contrib config
# Game index
######################################################################
GAME_INDEX_ENABLED = True
GAME_INDEX_LISTING = {
'game_status': 'pre-alpha',
# Optional, comment out or remove if N/A

View file

@ -9,7 +9,7 @@ Everything starts at handle_setup()
import time
from django.conf import settings
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from evennia.accounts.models import AccountDB
from evennia.server.models import ServerConfig
from evennia.utils import create, logger
@ -29,7 +29,7 @@ LIMBO_DESC = _(
"""
Welcome to your new |wEvennia|n-based game! Visit http://www.evennia.com if you need
help, want to contribute, report issues or just join the community.
As Account #1 you can create a demo/tutorial area with |w@batchcommand tutorial_world.build|n.
As Account #1 you can create a demo/tutorial area with '|wbatchcommand tutorial_world.build|n'.
"""
)
@ -95,7 +95,8 @@ def create_objects():
god_character.locks.add(
"examine:perm(Developer);edit:false();delete:false();boot:false();msg:all();puppet:false()"
)
god_character.permissions.add("Developer")
# we set this low so that quelling is more useful
god_character.permissions.add("Player")
god_account.attributes.add("_first_login", True)
god_account.attributes.add("_last_puppet", god_character)

View file

@ -164,7 +164,7 @@ def client_options(session, *args, **kwargs):
This allows the client an OOB way to inform us about its name and capabilities.
This will be integrated into the session settings
Kwargs:
Keyword Args:
get (bool): If this is true, return the settings as a dict
(ignore all other kwargs).
client (str): A client identifier, like "mushclient".
@ -282,7 +282,7 @@ def login(session, *args, **kwargs):
Peform a login. This only works if session is currently not logged
in. This will also automatically throttle too quick attempts.
Kwargs:
Keyword Args:
name (str): Account name
password (str): Plain-text password
@ -308,7 +308,7 @@ def get_value(session, *args, **kwargs):
Return the value of a given attribute or db_property on the
session's current account or character.
Kwargs:
Keyword Args:
name (str): Name of info value to return. Only names
in the _gettable dictionary earlier in this module
are accepted.
@ -325,7 +325,7 @@ def _testrepeat(**kwargs):
This is a test function for using with the repeat
inputfunc.
Kwargs:
Keyword Args:
session (Session): Session to return to.
"""
import time
@ -342,7 +342,7 @@ def repeat(session, *args, **kwargs):
this is meant as an example of limiting the number of
possible call functions.
Kwargs:
Keyword Args:
callback (str): The function to call. Only functions
from the _repeatable dictionary earlier in this
module are available.
@ -403,7 +403,7 @@ def monitor(session, *args, **kwargs):
"""
Adds monitoring to a given property or Attribute.
Kwargs:
Keyword Args:
name (str): The name of the property or Attribute
to report. No db_* prefix is needed. Only names
in the _monitorable dict earlier in this module
@ -485,8 +485,9 @@ def webclient_options(session, *args, **kwargs):
If kwargs is not empty, the key/values stored in there will be persisted
to the account object.
Kwargs:
Keyword Args:
<option name>: an option to save
"""
account = session.account
@ -521,9 +522,8 @@ def webclient_options(session, *args, **kwargs):
session=session,
)
else:
# kwargs provided: persist them to the account object
for key, value in kwargs.items():
clientoptions[key] = value
# kwargs provided: persist them to the account object.
clientoptions.update(kwargs)
# OOB protocol-specific aliases and wrappers
@ -576,8 +576,7 @@ def msdp_list(session, *args, **kwargs):
fieldnames = [tup[1] for tup in monitor_infos]
session.msg(reported_variables=(fieldnames, {}))
if "sendable_variables" in args_lower:
# no default sendable variables
session.msg(sendable_variables=([], {}))
session.msg(sendable_variables=(_monitorable, {}))
def msdp_report(session, *args, **kwargs):
@ -597,6 +596,17 @@ def msdp_unreport(session, *args, **kwargs):
unmonitor(session, *args, **kwargs)
def msdp_send(session, *args, **kwargs):
"""
MSDP SEND command
"""
out = {}
for varname in args:
if varname.lower() in _monitorable:
out[varname] = _monitorable[varname.lower()]
session.msg(send=((), out))
# client specific

View file

@ -46,6 +46,14 @@ NULNUL = b"\x00\x00"
AMP_MAXLEN = amp.MAX_VALUE_LENGTH # max allowed data length in AMP protocol (cannot be changed)
# amp internal
ASK = b'_ask'
ANSWER = b'_answer'
ERROR = b'_error'
ERROR_CODE = b'_error_code'
ERROR_DESCRIPTION = b'_error_description'
UNKNOWN_ERROR_CODE = b'UNKNOWN'
# buffers
_SENDBATCH = defaultdict(list)
_MSGBUFFER = defaultdict(list)
@ -301,6 +309,47 @@ class AMPMultiConnectionProtocol(amp.AMP):
# later twisted amp has its own __init__
super(AMPMultiConnectionProtocol, self).__init__(*args, **kwargs)
def _commandReceived(self, box):
"""
This overrides the default Twisted AMP error handling which is not
passing enough of the traceback through to the other side. Instead we
add a specific log of the problem on the erroring side.
"""
def formatAnswer(answerBox):
answerBox[ANSWER] = box[ASK]
return answerBox
def formatError(error):
if error.check(amp.RemoteAmpError):
code = error.value.errorCode
desc = error.value.description
# Evennia extra logging
desc += " (error logged on other side)"
_get_logger().log_err(f"AMP caught exception ({desc}):\n{error.value}")
if isinstance(desc, str):
desc = desc.encode("utf-8", "replace")
if error.value.fatal:
errorBox = amp.QuitBox()
else:
errorBox = amp.AmpBox()
else:
errorBox = amp.QuitBox()
_get_logger().log_err(error) # server-side logging if unhandled error
code = UNKNOWN_ERROR_CODE
desc = b"Unknown Error"
errorBox[ERROR] = box[ASK]
errorBox[ERROR_DESCRIPTION] = desc
errorBox[ERROR_CODE] = code
return errorBox
deferred = self.dispatchCommand(box)
if ASK in box:
deferred.addCallbacks(formatAnswer, formatError)
deferred.addCallback(self._safeEmit)
deferred.addErrback(self.unhandledError)
def dataReceived(self, data):
"""
Handle non-AMP messages, such as HTTP communication.
@ -314,7 +363,9 @@ class AMPMultiConnectionProtocol(amp.AMP):
try:
super(AMPMultiConnectionProtocol, self).dataReceived(data)
except KeyError:
_get_logger().log_trace("Discarded incoming partial data: {}".format(to_str(data)))
_get_logger().log_trace(
"Discarded incoming partial (packed) data (len {})".format(len(data))
)
elif self.multibatches:
# invalid AMP, but we have a pending multi-batch that is not yet complete
if data[-2:] == NULNUL:
@ -323,7 +374,9 @@ class AMPMultiConnectionProtocol(amp.AMP):
try:
super(AMPMultiConnectionProtocol, self).dataReceived(data)
except KeyError:
_get_logger().log_trace("Discarded incoming multi-batch data:".format(to_str(data)))
_get_logger().log_trace(
"Discarded incoming multi-batch (packed) data (len {})".format(len(data))
)
else:
# not an AMP communication, return warning
self.transport.write(_HTTP_WARNING)
@ -381,7 +434,7 @@ class AMPMultiConnectionProtocol(amp.AMP):
"""
e.trap(Exception)
_get_logger().log_err(
"AMP Error for {info}: {trcbck} {err}".format(
"AMP Error from {info}: {trcbck} {err}".format(
info=info, trcbck=e.getTraceback(), err=e.getErrorMessage()
)
)

View file

@ -159,7 +159,7 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol):
"""
# start the Server
print("Portal starting server ... {}".format(server_twistd_cmd))
print("Portal starting server ... ")
process = None
with open(settings.SERVER_LOG_FILE, "a") as logfile:
# we link stdout to a file in order to catch

View file

@ -303,7 +303,7 @@ class GrapevineClient(WebSocketClientProtocol, Session):
"""
Send data grapevine -> Evennia
Kwargs:
Keyword Args:
data (dict): Converted json data.
"""

View file

@ -275,7 +275,7 @@ class IRCBot(irc.IRCClient, Session):
"""
Data IRC -> Server.
Kwargs:
Keyword Args:
text (str): Ingoing text.
kwargs (any): Other data from protocol.
@ -306,7 +306,7 @@ class IRCBot(irc.IRCClient, Session):
Args:
text (str): Outgoing text.
Kwargs:
Keyword Args:
user (str): the nick to send
privately to.
@ -375,7 +375,7 @@ class IRCBotFactory(protocol.ReconnectingClientFactory):
Args:
sessionhandler (SessionHandler): Reference to the main Sessionhandler.
Kwargs:
Keyword Args:
uid (int): Bot user id.
botname (str): Bot name (seen in IRC channel).
channel (str): IRC channel to connect to.

View file

@ -15,9 +15,10 @@ This protocol is implemented by the telnet protocol importing
mccp_compress and calling it from its write methods.
"""
import zlib
from twisted.python.compat import _bytesChr as chr
# negotiations for v1 and v2 of the protocol
MCCP = b"\x56"
MCCP = chr(86) # b"\x56"
FLUSH = zlib.Z_SYNC_FLUSH

View file

@ -12,10 +12,11 @@ active players and so on.
"""
from django.conf import settings
from evennia.utils import utils
from twisted.python.compat import _bytesChr as bchr
MSSP = b"\x46"
MSSP_VAR = b"\x01"
MSSP_VAL = b"\x02"
MSSP = bchr(70) # b"\x46"
MSSP_VAR = bchr(1) # b"\x01"
MSSP_VAL = bchr(2) # b"\x02"
# try to get the customized mssp info, if it exists.
MSSPTable_CUSTOM = utils.variable_from_module(settings.MSSP_META_MODULE, "MSSPTable", default={})
@ -86,7 +87,7 @@ class Mssp(object):
"PLAYERS": self.get_player_count,
"UPTIME": self.get_uptime,
"PORT": list(
reversed(settings.TELNET_PORTS)
str(port) for port in reversed(settings.TELNET_PORTS)
), # most important port should be last in list
# Evennia auto-filled
"CRAWL DELAY": "-1",
@ -119,10 +120,15 @@ class Mssp(object):
if utils.is_iter(value):
for partval in value:
varlist += (
MSSP_VAR + bytes(variable, "utf-8") + MSSP_VAL + bytes(partval, "utf-8")
MSSP_VAR
+ bytes(str(variable), "utf-8")
+ MSSP_VAL
+ bytes(str(partval), "utf-8")
)
else:
varlist += MSSP_VAR + bytes(variable, "utf-8") + MSSP_VAL + bytes(value, "utf-8")
varlist += (
MSSP_VAR + bytes(str(variable), "utf-8") + MSSP_VAL + bytes(str(value), "utf-8")
)
# send to crawler by subnegotiation
self.protocol.requestNegotiation(MSSP, varlist)

View file

@ -14,11 +14,12 @@ http://www.gammon.com.au/mushclient/addingservermxp.htm
"""
import re
from twisted.python.compat import _bytesChr as bchr
LINKS_SUB = re.compile(r"\|lc(.*?)\|lt(.*?)\|le", re.DOTALL)
# MXP Telnet option
MXP = b"\x5b"
MXP = bchr(91) # b"\x5b"
MXP_TEMPSECURE = "\x1B[4z"
MXP_SEND = MXP_TEMPSECURE + '<SEND HREF="\\1">' + "\\2" + MXP_TEMPSECURE + "</SEND>"

View file

@ -11,9 +11,10 @@ client and update it when the size changes
"""
from codecs import encode as codecs_encode
from django.conf import settings
from twisted.python.compat import _bytesChr as bchr
NAWS = b"\x1f"
IS = b"\x00"
NAWS = bchr(31) # b"\x1f"
IS = bchr(0) # b"\x00"
# default taken from telnet specification
DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
DEFAULT_HEIGHT = settings.CLIENT_DEFAULT_HEIGHT

View file

@ -13,6 +13,7 @@ import time
from os.path import dirname, abspath
from twisted.application import internet, service
from twisted.internet.task import LoopingCall
from twisted.internet import protocol, reactor
from twisted.python.log import ILogObserver
@ -20,6 +21,7 @@ import django
django.setup()
from django.conf import settings
from django.db import connection
import evennia
@ -95,6 +97,35 @@ INFO_DICT = {
"webserver_internal": [],
}
try:
WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE)
except ImportError:
WEB_PLUGINS_MODULE = None
INFO_DICT["errors"] = (
"WARNING: settings.WEB_PLUGINS_MODULE not found - "
"copy 'evennia/game_template/server/conf/web_plugins.py to "
"mygame/server/conf."
)
_MAINTENANCE_COUNT = 0
def _portal_maintenance():
"""
Repeated maintenance tasks for the portal.
"""
global _MAINTENANCE_COUNT
_MAINTENANCE_COUNT += 1
if _MAINTENANCE_COUNT % (3600 * 7) == 0:
# drop database connection every 7 hrs to avoid default timeouts on MySQL
# (see https://github.com/evennia/evennia/issues/1376)
connection.close()
# -------------------------------------------------------------
# Portal Service object
# -------------------------------------------------------------
@ -133,6 +164,9 @@ class Portal(object):
self.start_time = time.time()
self.maintenance_task = LoopingCall(_portal_maintenance)
self.maintenance_task.start(60, now=True) # call every minute
# in non-interactive portal mode, this gets overwritten by
# cmdline sent by the evennia launcher
self.server_twistd_cmd = self._get_backup_server_twistd_cmd()
@ -190,7 +224,6 @@ class Portal(object):
self.sessions.disconnect_all()
if _stop_server:
self.amp_protocol.stop_server(mode="shutdown")
if not _reactor_stopping:
# shutting down the reactor will trigger another signal. We set
# a flag to avoid loops.
@ -213,7 +246,10 @@ application = service.Application("Portal")
if "--nodaemon" not in sys.argv:
logfile = logger.WeeklyLogFile(
os.path.basename(settings.PORTAL_LOG_FILE), os.path.dirname(settings.PORTAL_LOG_FILE)
os.path.basename(settings.PORTAL_LOG_FILE),
os.path.dirname(settings.PORTAL_LOG_FILE),
day_rotation=settings.PORTAL_LOG_DAY_ROTATION,
max_size=settings.PORTAL_LOG_MAX_SIZE,
)
application.setComponent(ILogObserver, logger.PortalLogObserver(logfile).emit)
@ -358,7 +394,7 @@ if WEBSERVER_ENABLED:
w_interface = WEBSOCKET_CLIENT_INTERFACE
w_ifacestr = ""
if w_interface not in ("0.0.0.0", "::") or len(WEBSERVER_INTERFACES) > 1:
w_ifacestr = "-%s" % interface
w_ifacestr = "-%s" % w_interface
port = WEBSOCKET_CLIENT_PORT
class Websocket(WebSocketServerFactory):
@ -376,6 +412,14 @@ if WEBSERVER_ENABLED:
webclientstr = "webclient-websocket%s: %s" % (w_ifacestr, port)
INFO_DICT["webclient"].append(webclientstr)
if WEB_PLUGINS_MODULE:
try:
web_root = WEB_PLUGINS_MODULE.at_webproxy_root_creation(web_root)
except Exception as e: # Legacy user has not added an at_webproxy_root_creation function in existing web plugins file
INFO_DICT["errors"] = (
"WARNING: WEB_PLUGINS_MODULE is enabled but at_webproxy_root_creation() not found - "
"copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf."
)
web_root = Website(web_root, logPath=settings.HTTP_LOG_FILE)
web_root.is_portal = True
proxy_service = internet.TCPServer(proxyport, web_root, interface=interface)

Some files were not shown because too many files have changed in this diff Show more