Cleaned up tests to use newly-renamed Account hooks for add/remove characters.

This commit is contained in:
Andrew Bastien 2023-05-07 21:27:33 -04:00
parent f782cd8fc8
commit 4b80b200d8
20 changed files with 774 additions and 50 deletions

View File

@ -622,7 +622,7 @@ node_apply_character(caller, raw_string, **kwargs):
tmp_character = kwargs["tmp_character"]
new_character = tmp_character.apply(caller)
caller.account.db._playable_characters = [new_character]
caller.account.add_character_to_playable_list(new_character)
text = "Character created!"

View File

@ -311,12 +311,12 @@ Our rock-paper-scissor setup works like this:
- `defend` does nothing but has a chance to beat `hit`.
- `flee/disengage` must succeed two times in a row (i.e. not beaten by a `hit` once during the turn). If so the character leaves combat.
```python
# mygame/world/rules.py
import random
# messages
def resolve_combat(combat_handler, actiondict):
@ -326,7 +326,7 @@ def resolve_combat(combat_handler, actiondict):
for each character:
{char.id:[(action1, char, target), (action2, char, target)], ...}
"""
flee = {} # track number of flee commands per character
flee = {} # track number of flee commands per character
for isub in range(2):
# loop over sub-turns
messages = []
@ -389,7 +389,7 @@ def resolve_combat(combat_handler, actiondict):
for (char, fleevalue) in flee.items():
if fleevalue == 2:
combat_handler.msg_all(f"{char} withdraws from combat.")
combat_handler.remove_character(char)
combat_handler.remove_character_from_playable_list(char)
```
To make it simple (and to save space), this example rule module actually resolves each interchange twice - first when it gets to each character and then again when handling the target. Also, since we use the combat handler's `msg_all` method here, the system will get pretty spammy. To clean it up, one could imagine tracking all the possible interactions to make sure each pair is only handled and reported once.
@ -403,6 +403,7 @@ This is the last component we need, a command to initiate combat. This will tie
from evennia import create_script
class CmdAttack(Command):
"""
initiates combat
@ -419,7 +420,7 @@ class CmdAttack(Command):
def func(self):
"Handle command"
if not self.args:
self.caller.msg("Usage: attack <target>")
self.caller.msg("Usage: attack <target>")
return
target = self.caller.search(self.args)
if not target:
@ -427,13 +428,13 @@ class CmdAttack(Command):
# set up combat
if target.ndb.combat_handler:
# target is already in combat - join it
target.ndb.combat_handler.add_character(self.caller)
target.ndb.combat_handler.add_character_to_playable_list(self.caller)
target.ndb.combat_handler.msg_all(f"{self.caller} joins combat!")
else:
# create a new combat handler
chandler = create_script("combat_handler.CombatHandler")
chandler.add_character(self.caller)
chandler.add_character(target)
chandler.add_character_to_playable_list(self.caller)
chandler.add_character_to_playable_list(target)
self.caller.msg(f"You attack {target}! You are in combat.")
target.msg(f"{self.caller} attacks you! You are in combat.")
```

View File

@ -206,7 +206,7 @@ def creating(request):
# create the character
char = create.create_object(typeclass=typeclass, key=name,
home=home, permissions=perms)
user.db._playable_characters.append(char)
user.add_character_to_playable_list(char)
# add the right locks for the character so the account can
# puppet it
char.locks.add(" or ".join([
@ -290,7 +290,7 @@ def creating(request):
# create the character
char = create.create_object(typeclass=typeclass, key=name,
home=home, permissions=perms)
user.db._playable_characters.append(char)
user.add_character_to_playable_list(char)
# add the right locks for the character so the account can
# puppet it
char.locks.add(" or ".join([

View File

@ -198,8 +198,8 @@ def index(request):
def index(request):
"""The 'index' view."""
user = request.user
if not user.is_anonymous() and user.db._playable_characters:
character = user.db._playable_characters[0]
if not user.is_anonymous() and user.characters:
character = user.characters[0]
```
In this second case, it will select the first character of the account.

View File

@ -186,6 +186,7 @@ def _init(portal_mode=False):
from .typeclasses.tags import TagCategoryProperty, TagProperty
from .utils import ansi, gametime, logger
from .utils.ansi import ANSIString
from .utils.evrich import install as install_evrich
# containers
from .utils.containers import GLOBAL_SCRIPTS, OPTION_CLASSES
@ -375,6 +376,9 @@ def _init(portal_mode=False):
del SystemCmds
del _EvContainer
# Trigger EvRich to monkey-patch Rich in-memory.
install_evrich()
# delayed starts - important so as to not back-access evennia before it has
# finished initializing
if not portal_mode:

View File

@ -236,15 +236,15 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
return objs
def add_character(self, character: "DefaultCharacter"):
def add_character_to_playable_list(self, character: "DefaultCharacter"):
"""
Add a character to this account's list of playable characters.
"""
if character not in self.db._playable_characters:
self.db._playable_characters.append(character)
self.at_post_add_character(character)
self.at_post_add_character_to_playable_list(character)
def at_post_add_character(self, character: "DefaultCharacter"):
def at_post_add_character_to_playable_list(self, character: "DefaultCharacter"):
"""
Called after a character is added to this account's list of playable characters.
@ -252,15 +252,15 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
"""
pass
def remove_character(self, character):
def remove_character_from_playable_list(self, character):
"""
Remove a character from this account's list of playable characters.
"""
if character in self.db._playable_characters:
self.db._playable_characters.remove(character)
self.at_post_remove_character(character)
self.at_post_remove_character_from_playable_list(character)
def at_post_remove_character(self, character):
def at_post_remove_character_from_playable_list(self, character):
"""
Called after a character is removed from this account's list of playable characters.
@ -776,7 +776,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
)
if character:
# Update playable character list
self.add_character(character)
self.add_character_to_playable_list(character)
# We need to set this to have @ic auto-connect to this character
self.db._last_puppet = character

View File

@ -105,14 +105,14 @@ class TestDefaultGuest(BaseEvenniaTest):
def test_at_server_shutdown(self):
account, errors = DefaultGuest.create(ip=self.ip)
self.char1.delete = MagicMock()
account.db._playable_characters = [self.char1]
account.add_character_to_playable_list(self.char1)
account.at_server_shutdown()
self.char1.delete.assert_called()
def test_at_post_disconnect(self):
account, errors = DefaultGuest.create(ip=self.ip)
self.char1.delete = MagicMock()
account.db._playable_characters = [self.char1]
account.add_character_to_playable_list(self.char1)
account.at_post_disconnect()
self.char1.delete.assert_called()
@ -358,19 +358,19 @@ class TestAccountPuppetDeletion(BaseEvenniaTest):
def test_puppet_deletion(self):
# Check for existing chars
self.assertFalse(
self.account.db._playable_characters, "Account should not have any chars by default."
self.account.characters, "Account should not have any chars by default."
)
# Add char1 to account's playable characters
self.account.db._playable_characters.append(self.char1)
self.assertTrue(self.account.db._playable_characters, "Char was not added to account.")
self.account.add_character_to_playable_list(self.char1)
self.assertTrue(self.account.characters, "Char was not added to account.")
# See what happens when we delete char1.
self.char1.delete()
# Playable char list should be empty.
self.assertFalse(
self.account.db._playable_characters,
f"Playable character list is not empty! {self.account.db._playable_characters}",
self.account.characters,
f"Playable character list is not empty! {self.account.characters}",
)
@ -387,6 +387,17 @@ class TestDefaultAccountEv(BaseEvenniaTest):
self.assertEqual(chars, [self.char1])
self.assertEqual(self.account.db._playable_characters, [self.char1])
def test_add_character_to_playable_list(self):
self.assertEqual(self.account.characters, [])
self.account.add_character_to_playable_list(self.char1)
self.assertEqual(self.account.characters, [self.char1])
def test_remove_character_from_playable_list(self):
self.account.add_character_to_playable_list(self.char1)
self.assertEqual(self.account.characters, [self.char1])
self.account.remove_character_from_playable_list(self.char1)
self.assertEqual(self.account.characters, [])
def test_puppet_success(self):
self.account.msg = MagicMock()
with patch("evennia.accounts.accounts._MULTISESSION_MODE", 2):

View File

@ -179,7 +179,7 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS):
"puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer);delete:id(%i) or"
" perm(Admin)" % (new_character.id, account.id, account.id)
)
account.add_character(new_character)
account.add_character_to_playable_list(new_character)
if desc:
new_character.db.desc = desc
elif not new_character.db.desc:
@ -238,7 +238,7 @@ class CmdCharDelete(COMMAND_DEFAULT_CLASS):
# only take action
delobj = caller.ndb._char_to_delete
key = delobj.key
caller.remove_character(delobj)
caller.remove_character_from_playable_list(delobj)
delobj.delete()
self.msg(f"Character '{key}' was permanently deleted.")
logger.log_sec(

View File

@ -589,7 +589,7 @@ class TestAccount(BaseEvenniaCommandTest):
]
)
def test_ooc_look(self, multisession_mode, auto_puppet, max_nr_chars, expected_result):
self.account.db._playable_characters = [self.char1]
self.account.add_character_to_playable_list(self.char1)
self.account.unpuppet_all()
with self.settings(MULTISESSION=multisession_mode):
@ -609,14 +609,14 @@ class TestAccount(BaseEvenniaCommandTest):
self.call(account.CmdOOC(), "", "You go OOC.", caller=self.account)
def test_ic(self):
self.account.db._playable_characters = [self.char1]
self.account.add_character_to_playable_list(self.char1)
self.account.unpuppet_object(self.session)
self.call(
account.CmdIC(), "Char", "You become Char.", caller=self.account, receiver=self.char1
)
def test_ic__other_object(self):
self.account.db._playable_characters = [self.obj1]
self.account.add_character_to_playable_list(self.obj1)
self.account.unpuppet_object(self.session)
self.call(
account.CmdIC(), "Obj", "You become Obj.", caller=self.account, receiver=self.obj1
@ -670,7 +670,7 @@ class TestAccount(BaseEvenniaCommandTest):
# whether permissions are being checked
# Add char to account playable characters
self.account.db._playable_characters.append(self.char1)
self.account.add_character_to_playable_list(self.char1)
# Try deleting as Developer
self.call(

View File

@ -507,7 +507,7 @@ def _create_character(session, new_account, typeclass, home, permissions):
typeclass, key=new_account.key, home=home, permissions=permissions
)
# set playable character list
new_account.add_character(new_character)
new_account.add_character_to_playable_list(new_character)
# allow only the character itself and the account to puppet this character (and Developers).
new_character.locks.add(

View File

@ -90,7 +90,7 @@ class ContribCmdCharCreate(MuxAccountCommand):
)
# initalize the new character to the beginning of the chargen menu
new_character.db.chargen_step = "menunode_welcome"
account.add_character(new_character)
account.add_character_to_playable_list(new_character)
# set the menu node to start at to the character's last saved step
startnode = new_character.db.chargen_step

View File

@ -17,7 +17,7 @@ class TestCharacterCreator(BaseEvenniaCommandTest):
self.account.swap_typeclass(character_creator.ContribChargenAccount)
def test_ooc_look(self):
self.account.db._playable_characters = [self.char1]
self.account.add_character_to_playable_list(self.char1)
self.account.unpuppet_all()
self.char1.db.chargen_step = "start"

View File

@ -316,7 +316,7 @@ def node_apply_character(caller, raw_string, **kwargs):
"""
tmp_character = kwargs["tmp_character"]
new_character = tmp_character.apply(caller)
caller.add_character(new_character)
caller.add_character_to_playable_list(new_character)
text = "Character created!"

View File

@ -1149,7 +1149,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
# sever the connection (important!)
if self.account:
# Remove the object from playable characters list
self.account.remove_character(self)
self.account.remove_character_from_playable_list(self)
for session in self.sessions.all():
self.account.unpuppet_object(session)
@ -2559,7 +2559,7 @@ class DefaultCharacter(DefaultObject):
obj.db.creator_ip = ip
if account:
obj.db.creator_id = account.id
account.add_character(obj)
account.add_character_to_playable_list(obj)
# Add locks
if not locks and account:

View File

@ -16,6 +16,8 @@ from evennia.scripts.monitorhandler import MONITOR_HANDLER
from evennia.typeclasses.attributes import AttributeHandler, DbHolder, InMemoryAttributeBackend
from evennia.utils import logger
from evennia.utils.utils import class_from_module, lazy_property, make_iter
from evennia.utils.evrich import MudConsole, MudConsoleOptions
from rich.color import ColorSystem
_GA = object.__getattribute__
_SA = object.__setattr__
@ -51,6 +53,57 @@ class ServerSession(_BASE_SESSION_CLASS):
self.cmdset_storage_string = ""
self.cmdset = CmdSetHandler(self, True)
@lazy_property
def console(self):
from mudrich import MudConsole
if "SCREENWIDTH" in self.protocol_flags:
width = self.protocol_flags["SCREENWIDTH"][0]
else:
width = 78
return MudConsole(color_system=self.rich_color_system(), width=width,
file=self, record=True)
def rich_color_system(self):
if self.protocol_flags.get("NOCOLOR", False):
return None
if self.protocol_flags.get("XTERM256", False):
return "256"
if self.protocol_flags.get("ANSI", False):
return "standard"
return None
def update_rich(self):
check = self.console
if "SCREENWIDTH" in self.protocol_flags:
self.console._width = self.protocol_flags["SCREENWIDTH"][0]
else:
self.console._width = 80
if self.protocol_flags.get("NOCOLOR", False):
self.console._color_system = None
elif self.protocol_flags.get("XTERM256", False):
self.console._color_system = ColorSystem.EIGHT_BIT
elif self.protocol_flags.get("ANSI", False):
self.console._color_system = ColorSystem.STANDARD
def write(self, b: str):
"""
When self.console.print() is called, it writes output to here.
Not necessarily useful, but it ensures console print doesn't end up sent out stdout or etc.
"""
def flush(self):
"""
Do not remove this method. It's needed to trick Console into treating this object
as a file.
"""
def print(self, *args, **kwargs) -> str:
"""
A thin wrapper around Rich.Console's print. Returns the exported data.
"""
self.console.print(*args, highlight=False, **kwargs)
return self.console.export_text(clear=True, styles=True)
def __cmdset_storage_get(self):
return [path.strip() for path in self.cmdset_storage_string.split(",")]
@ -257,6 +310,9 @@ class ServerSession(_BASE_SESSION_CLASS):
for the protocol(s).
"""
if (t := kwargs.get("text", None)):
if hasattr(t, "__rich_console__"):
kwargs["text"] = self.print(t)
self.sessionhandler.data_out(self, **kwargs)
def data_in(self, **kwargs):
@ -293,6 +349,8 @@ class ServerSession(_BASE_SESSION_CLASS):
kwargs.pop("session", None)
kwargs.pop("from_obj", None)
if text is not None:
if hasattr(text, "__rich_console__"):
text = self.print(text)
self.data_out(text=text, **kwargs)
else:
self.data_out(**kwargs)
@ -444,3 +502,7 @@ class ServerSession(_BASE_SESSION_CLASS):
return self.account.get_display_name(*args, **kwargs)
else:
return f"{self.protocol_key}({self.address})"
def load_sync_data(self, sessdata):
super().load_sync_data(sessdata)
self.update_rich()

View File

@ -72,6 +72,9 @@ from evennia.utils.utils import to_str
MXP_ENABLED = settings.MXP_ENABLED
from rich.ansi import AnsiDecoder
from .evrich import MudText
# ANSI definitions
@ -1054,6 +1057,13 @@ class ANSIString(str, metaclass=ANSIMeta):
result += self._raw_string[index]
return ANSIString(result + clean + append_tail, decoded=True)
def __rich_console__(self, console, options):
"""
Implements the Rich console API, allowing AnsiStrings to be
converted to MudText instances.
"""
yield MudText("\n").join(AnsiDecoder().decode(self))
def clean(self):
"""
Return a string object *without* the ANSI escapes.

635
evennia/utils/evrich.py Normal file
View File

@ -0,0 +1,635 @@
"""
This module installs monkey patches to Rich, allowing it to support MXP.
MudRich system, by Volund, ported the hard way to Evennia.
"""
import html
from dataclasses import dataclass
import random
import re
from marshal import loads, dumps
from typing import Any, Dict, Iterable, List, Optional, Type, Union, Tuple
from rich.color import Color, ColorSystem
from rich.style import Style as OLD_STYLE
from rich.text import Text as OLD_TEXT, Segment, Span
from rich.console import Console as OLD_CONSOLE, ConsoleOptions as OLD_CONSOLE_OPTIONS, NoChange, NO_CHANGE
from rich.console import JustifyMethod, OverflowMethod
_RE_SQUISH = re.compile("\S+")
_RE_NOTSPACE = re.compile("[^ ]+")
class MudStyle(OLD_STYLE):
_tag: str
__slots__ = [
"_tag",
"_xml_attr",
"_xml_attr_data"
]
def __init__(
self,
*,
color: Optional[Union[Color, str]] = None,
bgcolor: Optional[Union[Color, str]] = None,
bold: Optional[bool] = None,
dim: Optional[bool] = None,
italic: Optional[bool] = None,
underline: Optional[bool] = None,
blink: Optional[bool] = None,
blink2: Optional[bool] = None,
reverse: Optional[bool] = None,
conceal: Optional[bool] = None,
strike: Optional[bool] = None,
underline2: Optional[bool] = None,
frame: Optional[bool] = None,
encircle: Optional[bool] = None,
overline: Optional[bool] = None,
link: Optional[str] = None,
meta: Optional[Dict[str, Any]] = None,
tag: Optional[str] = None,
xml_attr: Optional[Dict] = None,
):
super().__init__(color=color, bgcolor=bgcolor, bold=bold, dim=dim, italic=italic,
underline=underline, blink=blink, blink2=blink2, reverse=reverse,
conceal=conceal, strike=strike, underline2=underline2, frame=frame,
encircle=encircle, overline=overline, link=link, meta=meta)
self._tag = tag
self._xml_attr = xml_attr
if self._xml_attr:
self._xml_attr_data = (
" ".join(f'{k}="{html.escape(v)}"' for k, v in xml_attr.items())
if xml_attr
else ""
)
else:
self._xml_attr_data = ""
self._hash = hash(
(
self._color,
self._bgcolor,
self._attributes,
self._set_attributes,
link,
self._meta,
tag,
self._xml_attr_data
)
)
self._null = not (self._set_attributes or color or bgcolor or link or meta or tag)
@classmethod
def upgrade(cls, old):
return cls.parse(str(old))
def render(
self,
text: str = "",
*,
color_system: Optional[ColorSystem] = ColorSystem.TRUECOLOR,
legacy_windows: bool = False,
mxp: bool = False,
pueblo: bool = False,
links: bool = True,
) -> str:
"""Render the ANSI codes for the style.
Args:
text (str, optional): A string to style. Defaults to "".
color_system (Optional[ColorSystem], optional): Color system to render to. Defaults to ColorSystem.TRUECOLOR.
Returns:
str: A string containing ANSI style codes.
"""
out_text = text
if mxp:
out_text = html.escape(out_text)
if not out_text:
return out_text
if color_system is not None:
attrs = self._make_ansi_codes(color_system)
rendered = f"\x1b[{attrs}m{out_text}\x1b[0m" if attrs else out_text
else:
rendered = out_text
if links and self._link and not legacy_windows:
rendered = (
f"\x1b]8;id={self._link_id};{self._link}\x1b\\{rendered}\x1b]8;;\x1b\\"
)
if (pueblo or mxp) and self._tag:
if mxp:
if self._xml_attr:
rendered = f"\x1b[4z<{self._tag} {self._xml_attr_data}>{rendered}\x1b[4z</{self._tag}>"
else:
rendered = f"\x1b[4z<{self._tag}>{rendered}\x1b[4z</{self._tag}>"
else:
if self._xml_attr:
rendered = (
f"{self._tag} {self._xml_attr_data}>{rendered}</{self._tag}>"
)
else:
rendered = f"<{self._tag}>{rendered}</{self._tag}>"
return rendered
def __add__(self, style: Union["Style", str]) -> "Style":
if isinstance(style, str):
style = self.__class__.parse(style)
if not (isinstance(style, MudStyle) or style is None):
return NotImplemented
if style is None or style._null:
return self
if self._null:
return style
new_style: MudStyle = self.__new__(MudStyle)
new_style._ansi = None
new_style._style_definition = None
new_style._color = style._color or self._color
new_style._bgcolor = style._bgcolor or self._bgcolor
new_style._attributes = (self._attributes & ~style._set_attributes) | (
style._attributes & style._set_attributes
)
new_style._set_attributes = self._set_attributes | style._set_attributes
new_style._link = style._link or self._link
new_style._link_id = style._link_id or self._link_id
new_style._tag = None
if hasattr(style, "_tag") and hasattr(self, "_tag"):
new_style._tag = style._tag or self._tag
new_style._xml_attr = None
if hasattr(style, "_xml_attr") and hasattr(self, "_xml_attr"):
new_style._xml_attr = style._xml_attr or self._xml_attr
new_style._xml_attr_data = ""
if hasattr(style, "_xml_attr_data") and hasattr(self, "_xml_attr_data"):
new_style._xml_attr_data = style._xml_attr_data or self._xml_attr_data
new_style._hash = style._hash
new_style._null = self._null or style._null
if self._meta and style._meta:
new_style._meta = dumps({**self.meta, **style.meta})
else:
new_style._meta = self._meta or style._meta
return new_style
def __radd__(self, other):
if isinstance(other, str):
other = self.__class__.parse(other)
return other + self
return NotImplemented
@dataclass
class MudConsoleOptions(OLD_CONSOLE_OPTIONS):
mxp: Optional[bool] = False
"""Enable MXP/MUD HTML when printing. For MUDs only."""
pueblo: Optional[bool] = False
"""Enable Pueblo/MUD HTML when printing. For MUDs only."""
links: Optional[bool] = True
"""Enable ANSI Links when printing. Turn off if MXP/Pueblo is on."""
def update(
self,
*,
width: Union[int, NoChange] = NO_CHANGE,
min_width: Union[int, NoChange] = NO_CHANGE,
max_width: Union[int, NoChange] = NO_CHANGE,
justify: Union[Optional[JustifyMethod], NoChange] = NO_CHANGE,
overflow: Union[Optional[OverflowMethod], NoChange] = NO_CHANGE,
no_wrap: Union[Optional[bool], NoChange] = NO_CHANGE,
highlight: Union[Optional[bool], NoChange] = NO_CHANGE,
markup: Union[Optional[bool], NoChange] = NO_CHANGE,
height: Union[Optional[int], NoChange] = NO_CHANGE,
mxp: Union[Optional[bool], NoChange] = NO_CHANGE,
pueblo: Union[Optional[bool], NoChange] = NO_CHANGE,
links: Union[Optional[bool], NoChange] = NO_CHANGE,
) -> "ConsoleOptions":
"""Update values, return a copy."""
options = self.copy()
if not isinstance(width, NoChange):
options.min_width = options.max_width = max(0, width)
if not isinstance(min_width, NoChange):
options.min_width = min_width
if not isinstance(max_width, NoChange):
options.max_width = max_width
if not isinstance(justify, NoChange):
options.justify = justify
if not isinstance(overflow, NoChange):
options.overflow = overflow
if not isinstance(no_wrap, NoChange):
options.no_wrap = no_wrap
if not isinstance(highlight, NoChange):
options.highlight = highlight
if not isinstance(markup, NoChange):
options.markup = markup
if not isinstance(height, NoChange):
options.height = None if height is None else max(0, height)
if not isinstance(mxp, NoChange):
options.mxp = mxp
if not isinstance(pueblo, NoChange):
options.pueblo = pueblo
if not isinstance(links, NoChange):
options.links = links
return options
class MudConsole(OLD_CONSOLE):
def __init__(self, **kwargs):
mxp = kwargs.pop("mxp", False)
pueblo = kwargs.pop("pueblo", False)
links = kwargs.pop("links", False)
super().__init__(**kwargs)
self._mxp = mxp
self._pueblo = pueblo
self._links = links
def export_text(self, *, clear: bool = True, styles: bool = False) -> str:
"""Generate text from console contents (requires record=True argument in constructor).
Args:
clear (bool, optional): Clear record buffer after exporting. Defaults to ``True``.
styles (bool, optional): If ``True``, ansi escape codes will be included. ``False`` for plain text.
Defaults to ``False``.
Returns:
str: String containing console contents.
"""
assert (
self.record
), "To export console contents set record=True in the constructor or instance"
with self._record_buffer_lock:
if styles:
text = "".join(
(style.render(
text,
color_system=self.color_system,
legacy_windows=self.legacy_windows,
mxp=self._mxp,
pueblo=self._pueblo,
links=self._links,
) if style else text)
for text, style, _ in self._record_buffer
)
else:
text = "".join(
segment.text
for segment in self._record_buffer
if not segment.control
)
if clear:
del self._record_buffer[:]
return text
def _render_buffer(self, buffer: Iterable[Segment]) -> str:
"""Render buffered output, and clear buffer."""
output: List[str] = []
append = output.append
color_system = self._color_system
legacy_windows = self.legacy_windows
not_terminal = not self.is_terminal
if self.no_color and color_system:
buffer = Segment.remove_color(buffer)
for text, style, control in buffer:
if style:
append(
style.render(
text,
color_system=color_system,
legacy_windows=legacy_windows,
mxp=self._mxp,
pueblo=self._pueblo,
links=self._links,
)
)
elif not (not_terminal and control):
append(text)
rendered = "".join(output)
return rendered
class MudText(OLD_TEXT):
def __radd__(self, other):
if isinstance(other, str):
other = self.__class__(text=other)
return other + self
return NotImplemented
def __iadd__(self, other: Any) -> "Text":
if isinstance(other, (str, OLD_TEXT)):
self.append(other)
return self
return NotImplemented
def __mul__(self, other):
if not isinstance(other, int):
return self
if other <= 0:
return self.__class__()
if other == 1:
return self.copy()
if other > 1:
out = self.copy()
for i in range(other - 1):
out.append(self)
return out
def __rmul__(self, other):
if not isinstance(other, int):
return self
return self * other
def __format__(self, format_spec):
"""
Allows use of f-strings, although styling is not preserved.
"""
return self.plain.__format__(format_spec)
# Begin implementing Python String Api below...
def capitalize(self):
return self.__class__(text=self.plain.capitalize(), style=self.style, spans=list(self.spans))
def count(self, *args, **kwargs):
return self.plain.count(*args, **kwargs)
def startswith(self, *args, **kwargs):
return self.plain.startswith(*args, **kwargs)
def endswith(self, *args, **kwargs):
return self.plain.endswith(*args, **kwargs)
def find(self, *args, **kwargs):
return self.plain.find(*args, **kwargs)
def index(self, *args, **kwargs):
return self.plain.index(*args, **kwargs)
def isalnum(self):
return self.plain.isalnum()
def isalpha(self):
return self.plain.isalpha()
def isdecimal(self):
return self.plain.isdecimal()
def isdigit(self):
return self.plain.isdigit()
def isidentifier(self):
return self.plain.isidentifier()
def islower(self):
return self.plain.islower()
def isnumeric(self):
return self.plain.isnumeric()
def isprintable(self):
return self.plain.isprintable()
def isspace(self):
return self.plain.isspace()
def istitle(self):
return self.plain.istitle()
def isupper(self):
return self.plain.isupper()
def center(self, width, fillchar=" "):
changed = self.plain.center(width, fillchar)
start = changed.find(self.plain)
lside = changed[:start]
rside = changed[len(lside) + len(self.plain):]
idx = self.disassemble_bits()
new_idx = list()
for c in lside:
new_idx.append((None, c))
new_idx.extend(idx)
for c in rside:
new_idx.append((None, c))
return self.__class__.assemble_bits(new_idx)
def ljust(self, width: int, fillchar: Union[str, "MudText"] = " "):
diff = width - len(self)
out = self.copy()
if diff <= 0:
return out
else:
if isinstance(fillchar, str):
fillchar = self.__class__(fillchar)
out.append(fillchar * diff)
return out
def rjust(self, width: int, fillchar: Union[str, "MudText"] = " "):
diff = width - len(self)
if diff <= 0:
return self.copy()
else:
if isinstance(fillchar, str):
fillchar = self.__class__(fillchar)
out = fillchar * diff
out.append(self)
return out
def lstrip(self, chars: str = None):
lstripped = self.plain.lstrip(chars)
strip_count = len(self.plain) - len(lstripped)
return self[strip_count:]
def strip(self, chars: str = " "):
out_map = self.disassemble_bits()
for i, e in enumerate(out_map):
if e[1] != chars:
out_map = out_map[i:]
break
out_map.reverse()
for i, e in enumerate(out_map):
if e[1] != chars:
out_map = out_map[i:]
break
out_map.reverse()
return self.__class__.assemble_bits(out_map)
def replace(self, old: str, new: Union[str, "Text"], count=None) -> "Text":
if not (indexes := self.find_all(old)):
return self.clone()
if count and count > 0:
indexes = indexes[:count]
old_len = len(old)
new_len = len(new)
other = self.clone()
markup_idx_map = self.disassemble_bits()
other_map = other.disassemble_bits()
for idx in reversed(indexes):
final_markup = markup_idx_map[idx + old_len][0]
diff = abs(old_len - new_len)
replace_chars = min(new_len, old_len)
# First, replace any characters that overlap.
for i in range(replace_chars):
other_map[idx + i] = (markup_idx_map[idx + i][0], new[i])
if old_len == new_len:
pass # the nicest case. nothing else needs doing.
elif old_len > new_len:
# slightly complex. pop off remaining characters.
for i in range(diff):
deleted = other_map.pop(idx + new_len)
elif new_len > old_len:
# slightly complex. insert new characters.
for i in range(diff):
other_map.insert(
idx + old_len + i, (final_markup, new[old_len + i])
)
return self.__class__.assemble_bits(other_map)
def find_all(self, sub: str):
indexes = list()
start = 0
while True:
start = self.plain.find(sub, start)
if start == -1:
return indexes
indexes.append(start)
start += len(sub)
def scramble(self):
idx = self.disassemble_bits()
random.shuffle(idx)
return self.__class__.assemble_bits(idx)
def reverse(self):
idx = self.disassemble_bits()
idx.reverse()
return self.__class__.assemble_bits(idx)
@classmethod
def assemble_bits(cls, idx: List[Tuple[Optional[Union[str, MudStyle, None]], str]]):
out = cls()
for i, t in enumerate(idx):
s = [Span(0, 1, t[0])]
out.append_text(cls(text=t[1], spans=s))
return out
def style_at_index(self, offset: int) -> MudStyle:
if offset < 0:
offset = len(self) + offset
style = MudStyle.null()
for start, end, span_style in self._spans:
if end > offset >= start:
style = style + span_style
return style
def disassemble_bits(self) -> List[Tuple[Optional[Union[str, MudStyle, None]], str]]:
idx = list()
for i, c in enumerate(self.plain):
idx.append((self.style_at_index(i), c))
return idx
def squish(self) -> "MudText":
"""
Removes leading and trailing whitespace, and coerces all internal whitespace sequences
into at most a single space. Returns the results.
"""
out = list()
matches = _RE_SQUISH.finditer(self.plain)
for match in matches:
out.append(self[match.start(): match.end()])
return self.__class__(" ").join(out)
def squish_spaces(self) -> "MudText":
"""
Like squish, but retains newlines and tabs. Just squishes spaces.
"""
out = list()
matches = _RE_NOTSPACE.finditer(self.plain)
for match in matches:
out.append(self[match.start(): match.end()])
return self.__class__(" ").join(out)
def serialize(self) -> dict:
def ser_style(style):
if isinstance(style, str):
style = MudStyle.parse(style)
if not isinstance(style, MudStyle):
style = MudStyle.upgrade(style)
return style.serialize()
def ser_span(span):
if not span.style:
return None
return {
"start": span.start,
"end": span.end,
"style": ser_style(span.style),
}
out = {"text": self.plain}
if self.style:
out["style"] = ser_style(self.style)
out_spans = [s for span in self.spans if (s := ser_span(span))]
if out_spans:
out["spans"] = out_spans
return out
@classmethod
def deserialize(cls, data) -> "Text":
text = data.get("text", None)
if text is None:
return cls("")
style = data.get("style", None)
if style:
style = MudStyle(**style)
spans = data.get("spans", None)
if spans:
spans = [Span(s["start"], s["end"], MudStyle(**s["style"])) for s in spans]
return cls(text=text, style=style, spans=spans)
DEFAULT_STYLES = dict()
def install():
from rich import style, text, console, default_styles, themes, syntax, traceback
global DEFAULT_STYLES
style.Style = MudStyle
style.NULL_STYLE = MudStyle()
text.Text = MudText
console.Console = MudConsole
console.ConsoleOptions = MudConsoleOptions
traceback.Style = MudStyle
syntax.Style = MudStyle
traceback.Text = MudText
syntax.Text = MudText
for k, v in default_styles.DEFAULT_STYLES.items():
DEFAULT_STYLES[k] = MudStyle.upgrade(v)
for theme in syntax.RICH_SYNTAX_THEMES.values():
for k, v in theme.items():
if isinstance(v, OLD_STYLE):
theme[k] = MudStyle.upgrade(v)
default_styles.DEFAULT_STYLES = DEFAULT_STYLES
themes.DEFAULT = themes.Theme(DEFAULT_STYLES)

View File

@ -319,7 +319,7 @@ class ObjectAdmin(admin.ModelAdmin):
if account:
account.db._last_puppet = obj
account.add_character(obj)
account.add_character_to_playable_list(obj)
if not obj.access(account, "puppet"):
lock = obj.locks.get("puppet")
lock += f" or pid({account.id})"

View File

@ -35,8 +35,8 @@ class EvenniaWebTest(BaseEvenniaTest):
super().setUp()
# Add chars to account rosters
self.account.db._playable_characters = [self.char1]
self.account2.db._playable_characters = [self.char2]
self.account.add_character_to_playable_list(self.char1)
self.account2.add_character_to_playable_list(self.char2)
for account in (self.account, self.account2):
# Demote accounts to Player permissions
@ -44,15 +44,15 @@ class EvenniaWebTest(BaseEvenniaTest):
account.permissions.remove("Developer")
# Grant permissions to chars
for char in account.db._playable_characters:
for char in account.characters:
char.locks.add("edit:id(%s) or perm(Admin)" % account.pk)
char.locks.add("delete:id(%s) or perm(Admin)" % account.pk)
char.locks.add("view:all()")
def test_valid_chars(self):
"Make sure account has playable characters"
self.assertTrue(self.char1 in self.account.db._playable_characters)
self.assertTrue(self.char2 in self.account2.db._playable_characters)
self.assertTrue(self.char1 in self.account.characters)
self.assertTrue(self.char2 in self.account2.characters)
def get_kwargs(self):
return {}
@ -220,7 +220,7 @@ class CharacterCreateView(EvenniaWebTest):
@override_settings(MAX_NR_CHARACTERS=1)
def test_valid_access_multisession_0(self):
"Account1 with no characters should be able to create a new one"
self.account.db._playable_characters = []
self.assertFalse(self.account.characters, "Account1 has characters but shouldn't!")
# Login account
self.login()
@ -233,9 +233,9 @@ class CharacterCreateView(EvenniaWebTest):
# Make sure the character was actually created
self.assertTrue(
len(self.account.db._playable_characters) == 1,
len(self.account.characters) == 1,
"Account only has the following characters attributed to it: %s"
% self.account.db._playable_characters,
% self.account.characters,
)
@override_settings(MAX_NR_CHARACTERS=5)
@ -252,9 +252,9 @@ class CharacterCreateView(EvenniaWebTest):
# Make sure the character was actually created
self.assertTrue(
len(self.account.db._playable_characters) > 1,
len(self.account.characters) > 1,
"Account only has the following characters attributed to it: %s"
% self.account.db._playable_characters,
% self.account.characters,
)
@ -352,7 +352,7 @@ class CharacterDeleteView(EvenniaWebTest):
# Make sure it deleted
self.assertFalse(
self.char1 in self.account.db._playable_characters,
self.char1 in self.account.characters,
"Char1 is still in Account playable characters list.",
)

View File

@ -84,6 +84,7 @@ dependencies = [
"black >= 22.6",
"isort >= 5.10",
"parameterized ==0.8.1",
"rich >= 13.3.5,
]
[project.optional-dependencies]