From 4b80b200d802b94edd6fb1191e69a827d87eb86e Mon Sep 17 00:00:00 2001 From: Andrew Bastien Date: Sun, 7 May 2023 21:27:33 -0400 Subject: [PATCH] Cleaned up tests to use newly-renamed Account hooks for add/remove characters. --- .../Part3/Beginner-Tutorial-Chargen.md | 2 +- .../source/Howtos/Turn-based-Combat-System.md | 15 +- .../source/Howtos/Web-Character-Generation.md | 4 +- .../source/Howtos/Web-Help-System-Tutorial.md | 4 +- evennia/__init__.py | 4 + evennia/accounts/accounts.py | 14 +- evennia/accounts/tests.py | 25 +- evennia/commands/default/account.py | 4 +- evennia/commands/default/tests.py | 8 +- evennia/commands/default/unloggedin.py | 2 +- .../character_creator/character_creator.py | 2 +- .../contrib/rpg/character_creator/tests.py | 2 +- .../contrib/tutorials/evadventure/chargen.py | 2 +- evennia/objects/objects.py | 4 +- evennia/server/serversession.py | 62 ++ evennia/utils/ansi.py | 10 + evennia/utils/evrich.py | 635 ++++++++++++++++++ evennia/web/admin/objects.py | 2 +- evennia/web/website/tests.py | 22 +- pyproject.toml | 1 + 20 files changed, 774 insertions(+), 50 deletions(-) create mode 100644 evennia/utils/evrich.py diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Chargen.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Chargen.md index 98ff3d0e82..9523bebaa1 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Chargen.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Chargen.md @@ -622,7 +622,7 @@ node_apply_character(caller, raw_string, **kwargs): tmp_character = kwargs["tmp_character"] new_character = tmp_character.apply(caller) - caller.account.db._playable_characters = [new_character] + caller.account.add_character_to_playable_list(new_character) text = "Character created!" diff --git a/docs/source/Howtos/Turn-based-Combat-System.md b/docs/source/Howtos/Turn-based-Combat-System.md index 3c90489fca..e823dee2d6 100644 --- a/docs/source/Howtos/Turn-based-Combat-System.md +++ b/docs/source/Howtos/Turn-based-Combat-System.md @@ -311,12 +311,12 @@ Our rock-paper-scissor setup works like this: - `defend` does nothing but has a chance to beat `hit`. - `flee/disengage` must succeed two times in a row (i.e. not beaten by a `hit` once during the turn). If so the character leaves combat. - ```python # mygame/world/rules.py import random + # messages def resolve_combat(combat_handler, actiondict): @@ -326,7 +326,7 @@ def resolve_combat(combat_handler, actiondict): for each character: {char.id:[(action1, char, target), (action2, char, target)], ...} """ - flee = {} # track number of flee commands per character + flee = {} # track number of flee commands per character for isub in range(2): # loop over sub-turns messages = [] @@ -389,7 +389,7 @@ def resolve_combat(combat_handler, actiondict): for (char, fleevalue) in flee.items(): if fleevalue == 2: combat_handler.msg_all(f"{char} withdraws from combat.") - combat_handler.remove_character(char) + combat_handler.remove_character_from_playable_list(char) ``` To make it simple (and to save space), this example rule module actually resolves each interchange twice - first when it gets to each character and then again when handling the target. Also, since we use the combat handler's `msg_all` method here, the system will get pretty spammy. To clean it up, one could imagine tracking all the possible interactions to make sure each pair is only handled and reported once. @@ -403,6 +403,7 @@ This is the last component we need, a command to initiate combat. This will tie from evennia import create_script + class CmdAttack(Command): """ initiates combat @@ -419,7 +420,7 @@ class CmdAttack(Command): def func(self): "Handle command" if not self.args: - self.caller.msg("Usage: attack ") + self.caller.msg("Usage: attack ") return target = self.caller.search(self.args) if not target: @@ -427,13 +428,13 @@ class CmdAttack(Command): # set up combat if target.ndb.combat_handler: # target is already in combat - join it - target.ndb.combat_handler.add_character(self.caller) + target.ndb.combat_handler.add_character_to_playable_list(self.caller) target.ndb.combat_handler.msg_all(f"{self.caller} joins combat!") else: # create a new combat handler chandler = create_script("combat_handler.CombatHandler") - chandler.add_character(self.caller) - chandler.add_character(target) + chandler.add_character_to_playable_list(self.caller) + chandler.add_character_to_playable_list(target) self.caller.msg(f"You attack {target}! You are in combat.") target.msg(f"{self.caller} attacks you! You are in combat.") ``` diff --git a/docs/source/Howtos/Web-Character-Generation.md b/docs/source/Howtos/Web-Character-Generation.md index caf30443d1..17680aab55 100644 --- a/docs/source/Howtos/Web-Character-Generation.md +++ b/docs/source/Howtos/Web-Character-Generation.md @@ -206,7 +206,7 @@ def creating(request): # create the character char = create.create_object(typeclass=typeclass, key=name, home=home, permissions=perms) - user.db._playable_characters.append(char) + user.add_character_to_playable_list(char) # add the right locks for the character so the account can # puppet it char.locks.add(" or ".join([ @@ -290,7 +290,7 @@ def creating(request): # create the character char = create.create_object(typeclass=typeclass, key=name, home=home, permissions=perms) - user.db._playable_characters.append(char) + user.add_character_to_playable_list(char) # add the right locks for the character so the account can # puppet it char.locks.add(" or ".join([ diff --git a/docs/source/Howtos/Web-Help-System-Tutorial.md b/docs/source/Howtos/Web-Help-System-Tutorial.md index 1864dc22b1..6afbede9c1 100644 --- a/docs/source/Howtos/Web-Help-System-Tutorial.md +++ b/docs/source/Howtos/Web-Help-System-Tutorial.md @@ -198,8 +198,8 @@ def index(request): def index(request): """The 'index' view.""" user = request.user - if not user.is_anonymous() and user.db._playable_characters: - character = user.db._playable_characters[0] + if not user.is_anonymous() and user.characters: + character = user.characters[0] ``` In this second case, it will select the first character of the account. diff --git a/evennia/__init__.py b/evennia/__init__.py index d1aac45bbb..bdebf67f6b 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -186,6 +186,7 @@ def _init(portal_mode=False): from .typeclasses.tags import TagCategoryProperty, TagProperty from .utils import ansi, gametime, logger from .utils.ansi import ANSIString + from .utils.evrich import install as install_evrich # containers from .utils.containers import GLOBAL_SCRIPTS, OPTION_CLASSES @@ -375,6 +376,9 @@ def _init(portal_mode=False): del SystemCmds del _EvContainer + # Trigger EvRich to monkey-patch Rich in-memory. + install_evrich() + # delayed starts - important so as to not back-access evennia before it has # finished initializing if not portal_mode: diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index d0608adf71..bc14b5bf04 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -236,15 +236,15 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): return objs - def add_character(self, character: "DefaultCharacter"): + def add_character_to_playable_list(self, character: "DefaultCharacter"): """ Add a character to this account's list of playable characters. """ if character not in self.db._playable_characters: self.db._playable_characters.append(character) - self.at_post_add_character(character) + self.at_post_add_character_to_playable_list(character) - def at_post_add_character(self, character: "DefaultCharacter"): + def at_post_add_character_to_playable_list(self, character: "DefaultCharacter"): """ Called after a character is added to this account's list of playable characters. @@ -252,15 +252,15 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): """ pass - def remove_character(self, character): + def remove_character_from_playable_list(self, character): """ Remove a character from this account's list of playable characters. """ if character in self.db._playable_characters: self.db._playable_characters.remove(character) - self.at_post_remove_character(character) + self.at_post_remove_character_from_playable_list(character) - def at_post_remove_character(self, character): + def at_post_remove_character_from_playable_list(self, character): """ Called after a character is removed from this account's list of playable characters. @@ -776,7 +776,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): ) if character: # Update playable character list - self.add_character(character) + self.add_character_to_playable_list(character) # We need to set this to have @ic auto-connect to this character self.db._last_puppet = character diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 384f9f882d..c356095895 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -105,14 +105,14 @@ class TestDefaultGuest(BaseEvenniaTest): def test_at_server_shutdown(self): account, errors = DefaultGuest.create(ip=self.ip) self.char1.delete = MagicMock() - account.db._playable_characters = [self.char1] + account.add_character_to_playable_list(self.char1) account.at_server_shutdown() self.char1.delete.assert_called() def test_at_post_disconnect(self): account, errors = DefaultGuest.create(ip=self.ip) self.char1.delete = MagicMock() - account.db._playable_characters = [self.char1] + account.add_character_to_playable_list(self.char1) account.at_post_disconnect() self.char1.delete.assert_called() @@ -358,19 +358,19 @@ class TestAccountPuppetDeletion(BaseEvenniaTest): def test_puppet_deletion(self): # Check for existing chars self.assertFalse( - self.account.db._playable_characters, "Account should not have any chars by default." + self.account.characters, "Account should not have any chars by default." ) # Add char1 to account's playable characters - self.account.db._playable_characters.append(self.char1) - self.assertTrue(self.account.db._playable_characters, "Char was not added to account.") + self.account.add_character_to_playable_list(self.char1) + self.assertTrue(self.account.characters, "Char was not added to account.") # See what happens when we delete char1. self.char1.delete() # Playable char list should be empty. self.assertFalse( - self.account.db._playable_characters, - f"Playable character list is not empty! {self.account.db._playable_characters}", + self.account.characters, + f"Playable character list is not empty! {self.account.characters}", ) @@ -387,6 +387,17 @@ class TestDefaultAccountEv(BaseEvenniaTest): self.assertEqual(chars, [self.char1]) self.assertEqual(self.account.db._playable_characters, [self.char1]) + def test_add_character_to_playable_list(self): + self.assertEqual(self.account.characters, []) + self.account.add_character_to_playable_list(self.char1) + self.assertEqual(self.account.characters, [self.char1]) + + def test_remove_character_from_playable_list(self): + self.account.add_character_to_playable_list(self.char1) + self.assertEqual(self.account.characters, [self.char1]) + self.account.remove_character_from_playable_list(self.char1) + self.assertEqual(self.account.characters, []) + def test_puppet_success(self): self.account.msg = MagicMock() with patch("evennia.accounts.accounts._MULTISESSION_MODE", 2): diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index 5e3f2f4182..571d9f7eeb 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -179,7 +179,7 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS): "puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer);delete:id(%i) or" " perm(Admin)" % (new_character.id, account.id, account.id) ) - account.add_character(new_character) + account.add_character_to_playable_list(new_character) if desc: new_character.db.desc = desc elif not new_character.db.desc: @@ -238,7 +238,7 @@ class CmdCharDelete(COMMAND_DEFAULT_CLASS): # only take action delobj = caller.ndb._char_to_delete key = delobj.key - caller.remove_character(delobj) + caller.remove_character_from_playable_list(delobj) delobj.delete() self.msg(f"Character '{key}' was permanently deleted.") logger.log_sec( diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index c499e6ada0..0e25737e80 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -589,7 +589,7 @@ class TestAccount(BaseEvenniaCommandTest): ] ) def test_ooc_look(self, multisession_mode, auto_puppet, max_nr_chars, expected_result): - self.account.db._playable_characters = [self.char1] + self.account.add_character_to_playable_list(self.char1) self.account.unpuppet_all() with self.settings(MULTISESSION=multisession_mode): @@ -609,14 +609,14 @@ class TestAccount(BaseEvenniaCommandTest): self.call(account.CmdOOC(), "", "You go OOC.", caller=self.account) def test_ic(self): - self.account.db._playable_characters = [self.char1] + self.account.add_character_to_playable_list(self.char1) self.account.unpuppet_object(self.session) self.call( account.CmdIC(), "Char", "You become Char.", caller=self.account, receiver=self.char1 ) def test_ic__other_object(self): - self.account.db._playable_characters = [self.obj1] + self.account.add_character_to_playable_list(self.obj1) self.account.unpuppet_object(self.session) self.call( account.CmdIC(), "Obj", "You become Obj.", caller=self.account, receiver=self.obj1 @@ -670,7 +670,7 @@ class TestAccount(BaseEvenniaCommandTest): # whether permissions are being checked # Add char to account playable characters - self.account.db._playable_characters.append(self.char1) + self.account.add_character_to_playable_list(self.char1) # Try deleting as Developer self.call( diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index 0b0179b3dd..c4b7c726a3 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -507,7 +507,7 @@ def _create_character(session, new_account, typeclass, home, permissions): typeclass, key=new_account.key, home=home, permissions=permissions ) # set playable character list - new_account.add_character(new_character) + new_account.add_character_to_playable_list(new_character) # allow only the character itself and the account to puppet this character (and Developers). new_character.locks.add( diff --git a/evennia/contrib/rpg/character_creator/character_creator.py b/evennia/contrib/rpg/character_creator/character_creator.py index d70a6be0a5..b6c0ae51e3 100644 --- a/evennia/contrib/rpg/character_creator/character_creator.py +++ b/evennia/contrib/rpg/character_creator/character_creator.py @@ -90,7 +90,7 @@ class ContribCmdCharCreate(MuxAccountCommand): ) # initalize the new character to the beginning of the chargen menu new_character.db.chargen_step = "menunode_welcome" - account.add_character(new_character) + account.add_character_to_playable_list(new_character) # set the menu node to start at to the character's last saved step startnode = new_character.db.chargen_step diff --git a/evennia/contrib/rpg/character_creator/tests.py b/evennia/contrib/rpg/character_creator/tests.py index 4ff180a3dd..e6aec7fd97 100644 --- a/evennia/contrib/rpg/character_creator/tests.py +++ b/evennia/contrib/rpg/character_creator/tests.py @@ -17,7 +17,7 @@ class TestCharacterCreator(BaseEvenniaCommandTest): self.account.swap_typeclass(character_creator.ContribChargenAccount) def test_ooc_look(self): - self.account.db._playable_characters = [self.char1] + self.account.add_character_to_playable_list(self.char1) self.account.unpuppet_all() self.char1.db.chargen_step = "start" diff --git a/evennia/contrib/tutorials/evadventure/chargen.py b/evennia/contrib/tutorials/evadventure/chargen.py index 2236ddd7f1..fed7b1c761 100644 --- a/evennia/contrib/tutorials/evadventure/chargen.py +++ b/evennia/contrib/tutorials/evadventure/chargen.py @@ -316,7 +316,7 @@ def node_apply_character(caller, raw_string, **kwargs): """ tmp_character = kwargs["tmp_character"] new_character = tmp_character.apply(caller) - caller.add_character(new_character) + caller.add_character_to_playable_list(new_character) text = "Character created!" diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 5e22cc03b0..4b6605efa6 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -1149,7 +1149,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): # sever the connection (important!) if self.account: # Remove the object from playable characters list - self.account.remove_character(self) + self.account.remove_character_from_playable_list(self) for session in self.sessions.all(): self.account.unpuppet_object(session) @@ -2559,7 +2559,7 @@ class DefaultCharacter(DefaultObject): obj.db.creator_ip = ip if account: obj.db.creator_id = account.id - account.add_character(obj) + account.add_character_to_playable_list(obj) # Add locks if not locks and account: diff --git a/evennia/server/serversession.py b/evennia/server/serversession.py index 9eeb186a26..cf8d3acc68 100644 --- a/evennia/server/serversession.py +++ b/evennia/server/serversession.py @@ -16,6 +16,8 @@ from evennia.scripts.monitorhandler import MONITOR_HANDLER from evennia.typeclasses.attributes import AttributeHandler, DbHolder, InMemoryAttributeBackend from evennia.utils import logger from evennia.utils.utils import class_from_module, lazy_property, make_iter +from evennia.utils.evrich import MudConsole, MudConsoleOptions +from rich.color import ColorSystem _GA = object.__getattribute__ _SA = object.__setattr__ @@ -51,6 +53,57 @@ class ServerSession(_BASE_SESSION_CLASS): self.cmdset_storage_string = "" self.cmdset = CmdSetHandler(self, True) + @lazy_property + def console(self): + from mudrich import MudConsole + if "SCREENWIDTH" in self.protocol_flags: + width = self.protocol_flags["SCREENWIDTH"][0] + else: + width = 78 + return MudConsole(color_system=self.rich_color_system(), width=width, + file=self, record=True) + + def rich_color_system(self): + if self.protocol_flags.get("NOCOLOR", False): + return None + if self.protocol_flags.get("XTERM256", False): + return "256" + if self.protocol_flags.get("ANSI", False): + return "standard" + return None + + def update_rich(self): + check = self.console + if "SCREENWIDTH" in self.protocol_flags: + self.console._width = self.protocol_flags["SCREENWIDTH"][0] + else: + self.console._width = 80 + if self.protocol_flags.get("NOCOLOR", False): + self.console._color_system = None + elif self.protocol_flags.get("XTERM256", False): + self.console._color_system = ColorSystem.EIGHT_BIT + elif self.protocol_flags.get("ANSI", False): + self.console._color_system = ColorSystem.STANDARD + + def write(self, b: str): + """ + When self.console.print() is called, it writes output to here. + Not necessarily useful, but it ensures console print doesn't end up sent out stdout or etc. + """ + + def flush(self): + """ + Do not remove this method. It's needed to trick Console into treating this object + as a file. + """ + + def print(self, *args, **kwargs) -> str: + """ + A thin wrapper around Rich.Console's print. Returns the exported data. + """ + self.console.print(*args, highlight=False, **kwargs) + return self.console.export_text(clear=True, styles=True) + def __cmdset_storage_get(self): return [path.strip() for path in self.cmdset_storage_string.split(",")] @@ -257,6 +310,9 @@ class ServerSession(_BASE_SESSION_CLASS): for the protocol(s). """ + if (t := kwargs.get("text", None)): + if hasattr(t, "__rich_console__"): + kwargs["text"] = self.print(t) self.sessionhandler.data_out(self, **kwargs) def data_in(self, **kwargs): @@ -293,6 +349,8 @@ class ServerSession(_BASE_SESSION_CLASS): kwargs.pop("session", None) kwargs.pop("from_obj", None) if text is not None: + if hasattr(text, "__rich_console__"): + text = self.print(text) self.data_out(text=text, **kwargs) else: self.data_out(**kwargs) @@ -444,3 +502,7 @@ class ServerSession(_BASE_SESSION_CLASS): return self.account.get_display_name(*args, **kwargs) else: return f"{self.protocol_key}({self.address})" + + def load_sync_data(self, sessdata): + super().load_sync_data(sessdata) + self.update_rich() diff --git a/evennia/utils/ansi.py b/evennia/utils/ansi.py index cfa7192477..ff2c2cdd69 100644 --- a/evennia/utils/ansi.py +++ b/evennia/utils/ansi.py @@ -72,6 +72,9 @@ from evennia.utils.utils import to_str MXP_ENABLED = settings.MXP_ENABLED +from rich.ansi import AnsiDecoder +from .evrich import MudText + # ANSI definitions @@ -1054,6 +1057,13 @@ class ANSIString(str, metaclass=ANSIMeta): result += self._raw_string[index] return ANSIString(result + clean + append_tail, decoded=True) + def __rich_console__(self, console, options): + """ + Implements the Rich console API, allowing AnsiStrings to be + converted to MudText instances. + """ + yield MudText("\n").join(AnsiDecoder().decode(self)) + def clean(self): """ Return a string object *without* the ANSI escapes. diff --git a/evennia/utils/evrich.py b/evennia/utils/evrich.py new file mode 100644 index 0000000000..85fc15cba0 --- /dev/null +++ b/evennia/utils/evrich.py @@ -0,0 +1,635 @@ +""" +This module installs monkey patches to Rich, allowing it to support MXP. + +MudRich system, by Volund, ported the hard way to Evennia. +""" +import html +from dataclasses import dataclass +import random +import re +from marshal import loads, dumps + +from typing import Any, Dict, Iterable, List, Optional, Type, Union, Tuple + +from rich.color import Color, ColorSystem + +from rich.style import Style as OLD_STYLE +from rich.text import Text as OLD_TEXT, Segment, Span +from rich.console import Console as OLD_CONSOLE, ConsoleOptions as OLD_CONSOLE_OPTIONS, NoChange, NO_CHANGE +from rich.console import JustifyMethod, OverflowMethod + + +_RE_SQUISH = re.compile("\S+") +_RE_NOTSPACE = re.compile("[^ ]+") + + +class MudStyle(OLD_STYLE): + _tag: str + + __slots__ = [ + "_tag", + "_xml_attr", + "_xml_attr_data" + ] + + def __init__( + self, + *, + color: Optional[Union[Color, str]] = None, + bgcolor: Optional[Union[Color, str]] = None, + bold: Optional[bool] = None, + dim: Optional[bool] = None, + italic: Optional[bool] = None, + underline: Optional[bool] = None, + blink: Optional[bool] = None, + blink2: Optional[bool] = None, + reverse: Optional[bool] = None, + conceal: Optional[bool] = None, + strike: Optional[bool] = None, + underline2: Optional[bool] = None, + frame: Optional[bool] = None, + encircle: Optional[bool] = None, + overline: Optional[bool] = None, + link: Optional[str] = None, + meta: Optional[Dict[str, Any]] = None, + tag: Optional[str] = None, + xml_attr: Optional[Dict] = None, + ): + super().__init__(color=color, bgcolor=bgcolor, bold=bold, dim=dim, italic=italic, + underline=underline, blink=blink, blink2=blink2, reverse=reverse, + conceal=conceal, strike=strike, underline2=underline2, frame=frame, + encircle=encircle, overline=overline, link=link, meta=meta) + + self._tag = tag + self._xml_attr = xml_attr + if self._xml_attr: + self._xml_attr_data = ( + " ".join(f'{k}="{html.escape(v)}"' for k, v in xml_attr.items()) + if xml_attr + else "" + ) + else: + self._xml_attr_data = "" + + self._hash = hash( + ( + self._color, + self._bgcolor, + self._attributes, + self._set_attributes, + link, + self._meta, + tag, + self._xml_attr_data + ) + ) + + self._null = not (self._set_attributes or color or bgcolor or link or meta or tag) + + @classmethod + def upgrade(cls, old): + return cls.parse(str(old)) + + def render( + self, + text: str = "", + *, + color_system: Optional[ColorSystem] = ColorSystem.TRUECOLOR, + legacy_windows: bool = False, + mxp: bool = False, + pueblo: bool = False, + links: bool = True, + ) -> str: + """Render the ANSI codes for the style. + + Args: + text (str, optional): A string to style. Defaults to "". + color_system (Optional[ColorSystem], optional): Color system to render to. Defaults to ColorSystem.TRUECOLOR. + + Returns: + str: A string containing ANSI style codes. + """ + out_text = text + if mxp: + out_text = html.escape(out_text) + if not out_text: + return out_text + if color_system is not None: + attrs = self._make_ansi_codes(color_system) + rendered = f"\x1b[{attrs}m{out_text}\x1b[0m" if attrs else out_text + else: + rendered = out_text + if links and self._link and not legacy_windows: + rendered = ( + f"\x1b]8;id={self._link_id};{self._link}\x1b\\{rendered}\x1b]8;;\x1b\\" + ) + if (pueblo or mxp) and self._tag: + if mxp: + if self._xml_attr: + rendered = f"\x1b[4z<{self._tag} {self._xml_attr_data}>{rendered}\x1b[4z" + else: + rendered = f"\x1b[4z<{self._tag}>{rendered}\x1b[4z" + else: + if self._xml_attr: + rendered = ( + f"{self._tag} {self._xml_attr_data}>{rendered}" + ) + else: + rendered = f"<{self._tag}>{rendered}" + return rendered + + def __add__(self, style: Union["Style", str]) -> "Style": + if isinstance(style, str): + style = self.__class__.parse(style) + if not (isinstance(style, MudStyle) or style is None): + return NotImplemented + if style is None or style._null: + return self + if self._null: + return style + new_style: MudStyle = self.__new__(MudStyle) + new_style._ansi = None + new_style._style_definition = None + new_style._color = style._color or self._color + new_style._bgcolor = style._bgcolor or self._bgcolor + new_style._attributes = (self._attributes & ~style._set_attributes) | ( + style._attributes & style._set_attributes + ) + new_style._set_attributes = self._set_attributes | style._set_attributes + new_style._link = style._link or self._link + new_style._link_id = style._link_id or self._link_id + + new_style._tag = None + if hasattr(style, "_tag") and hasattr(self, "_tag"): + new_style._tag = style._tag or self._tag + + new_style._xml_attr = None + if hasattr(style, "_xml_attr") and hasattr(self, "_xml_attr"): + new_style._xml_attr = style._xml_attr or self._xml_attr + + new_style._xml_attr_data = "" + if hasattr(style, "_xml_attr_data") and hasattr(self, "_xml_attr_data"): + new_style._xml_attr_data = style._xml_attr_data or self._xml_attr_data + + new_style._hash = style._hash + new_style._null = self._null or style._null + if self._meta and style._meta: + new_style._meta = dumps({**self.meta, **style.meta}) + else: + new_style._meta = self._meta or style._meta + + return new_style + + def __radd__(self, other): + if isinstance(other, str): + other = self.__class__.parse(other) + return other + self + return NotImplemented + + +@dataclass +class MudConsoleOptions(OLD_CONSOLE_OPTIONS): + mxp: Optional[bool] = False + """Enable MXP/MUD HTML when printing. For MUDs only.""" + pueblo: Optional[bool] = False + """Enable Pueblo/MUD HTML when printing. For MUDs only.""" + links: Optional[bool] = True + """Enable ANSI Links when printing. Turn off if MXP/Pueblo is on.""" + + def update( + self, + *, + width: Union[int, NoChange] = NO_CHANGE, + min_width: Union[int, NoChange] = NO_CHANGE, + max_width: Union[int, NoChange] = NO_CHANGE, + justify: Union[Optional[JustifyMethod], NoChange] = NO_CHANGE, + overflow: Union[Optional[OverflowMethod], NoChange] = NO_CHANGE, + no_wrap: Union[Optional[bool], NoChange] = NO_CHANGE, + highlight: Union[Optional[bool], NoChange] = NO_CHANGE, + markup: Union[Optional[bool], NoChange] = NO_CHANGE, + height: Union[Optional[int], NoChange] = NO_CHANGE, + mxp: Union[Optional[bool], NoChange] = NO_CHANGE, + pueblo: Union[Optional[bool], NoChange] = NO_CHANGE, + links: Union[Optional[bool], NoChange] = NO_CHANGE, + ) -> "ConsoleOptions": + """Update values, return a copy.""" + options = self.copy() + if not isinstance(width, NoChange): + options.min_width = options.max_width = max(0, width) + if not isinstance(min_width, NoChange): + options.min_width = min_width + if not isinstance(max_width, NoChange): + options.max_width = max_width + if not isinstance(justify, NoChange): + options.justify = justify + if not isinstance(overflow, NoChange): + options.overflow = overflow + if not isinstance(no_wrap, NoChange): + options.no_wrap = no_wrap + if not isinstance(highlight, NoChange): + options.highlight = highlight + if not isinstance(markup, NoChange): + options.markup = markup + if not isinstance(height, NoChange): + options.height = None if height is None else max(0, height) + if not isinstance(mxp, NoChange): + options.mxp = mxp + if not isinstance(pueblo, NoChange): + options.pueblo = pueblo + if not isinstance(links, NoChange): + options.links = links + return options + + +class MudConsole(OLD_CONSOLE): + + def __init__(self, **kwargs): + mxp = kwargs.pop("mxp", False) + pueblo = kwargs.pop("pueblo", False) + links = kwargs.pop("links", False) + super().__init__(**kwargs) + + self._mxp = mxp + self._pueblo = pueblo + self._links = links + + def export_text(self, *, clear: bool = True, styles: bool = False) -> str: + """Generate text from console contents (requires record=True argument in constructor). + Args: + clear (bool, optional): Clear record buffer after exporting. Defaults to ``True``. + styles (bool, optional): If ``True``, ansi escape codes will be included. ``False`` for plain text. + Defaults to ``False``. + Returns: + str: String containing console contents. + """ + assert ( + self.record + ), "To export console contents set record=True in the constructor or instance" + + with self._record_buffer_lock: + if styles: + text = "".join( + (style.render( + text, + color_system=self.color_system, + legacy_windows=self.legacy_windows, + mxp=self._mxp, + pueblo=self._pueblo, + links=self._links, + ) if style else text) + for text, style, _ in self._record_buffer + ) + else: + text = "".join( + segment.text + for segment in self._record_buffer + if not segment.control + ) + if clear: + del self._record_buffer[:] + return text + + def _render_buffer(self, buffer: Iterable[Segment]) -> str: + """Render buffered output, and clear buffer.""" + output: List[str] = [] + append = output.append + color_system = self._color_system + legacy_windows = self.legacy_windows + not_terminal = not self.is_terminal + if self.no_color and color_system: + buffer = Segment.remove_color(buffer) + for text, style, control in buffer: + if style: + append( + style.render( + text, + color_system=color_system, + legacy_windows=legacy_windows, + mxp=self._mxp, + pueblo=self._pueblo, + links=self._links, + ) + ) + elif not (not_terminal and control): + append(text) + + rendered = "".join(output) + return rendered + + +class MudText(OLD_TEXT): + + def __radd__(self, other): + if isinstance(other, str): + other = self.__class__(text=other) + return other + self + return NotImplemented + + def __iadd__(self, other: Any) -> "Text": + if isinstance(other, (str, OLD_TEXT)): + self.append(other) + return self + return NotImplemented + + def __mul__(self, other): + if not isinstance(other, int): + return self + if other <= 0: + return self.__class__() + if other == 1: + return self.copy() + if other > 1: + out = self.copy() + for i in range(other - 1): + out.append(self) + return out + + def __rmul__(self, other): + if not isinstance(other, int): + return self + return self * other + + def __format__(self, format_spec): + """ + Allows use of f-strings, although styling is not preserved. + """ + return self.plain.__format__(format_spec) + + # Begin implementing Python String Api below... + + def capitalize(self): + return self.__class__(text=self.plain.capitalize(), style=self.style, spans=list(self.spans)) + + def count(self, *args, **kwargs): + return self.plain.count(*args, **kwargs) + + def startswith(self, *args, **kwargs): + return self.plain.startswith(*args, **kwargs) + + def endswith(self, *args, **kwargs): + return self.plain.endswith(*args, **kwargs) + + def find(self, *args, **kwargs): + return self.plain.find(*args, **kwargs) + + def index(self, *args, **kwargs): + return self.plain.index(*args, **kwargs) + + def isalnum(self): + return self.plain.isalnum() + + def isalpha(self): + return self.plain.isalpha() + + def isdecimal(self): + return self.plain.isdecimal() + + def isdigit(self): + return self.plain.isdigit() + + def isidentifier(self): + return self.plain.isidentifier() + + def islower(self): + return self.plain.islower() + + def isnumeric(self): + return self.plain.isnumeric() + + def isprintable(self): + return self.plain.isprintable() + + def isspace(self): + return self.plain.isspace() + + def istitle(self): + return self.plain.istitle() + + def isupper(self): + return self.plain.isupper() + + def center(self, width, fillchar=" "): + changed = self.plain.center(width, fillchar) + start = changed.find(self.plain) + lside = changed[:start] + rside = changed[len(lside) + len(self.plain):] + idx = self.disassemble_bits() + new_idx = list() + for c in lside: + new_idx.append((None, c)) + new_idx.extend(idx) + for c in rside: + new_idx.append((None, c)) + return self.__class__.assemble_bits(new_idx) + + def ljust(self, width: int, fillchar: Union[str, "MudText"] = " "): + diff = width - len(self) + out = self.copy() + if diff <= 0: + return out + else: + if isinstance(fillchar, str): + fillchar = self.__class__(fillchar) + out.append(fillchar * diff) + return out + + def rjust(self, width: int, fillchar: Union[str, "MudText"] = " "): + diff = width - len(self) + if diff <= 0: + return self.copy() + else: + if isinstance(fillchar, str): + fillchar = self.__class__(fillchar) + out = fillchar * diff + out.append(self) + return out + + def lstrip(self, chars: str = None): + lstripped = self.plain.lstrip(chars) + strip_count = len(self.plain) - len(lstripped) + return self[strip_count:] + + def strip(self, chars: str = " "): + out_map = self.disassemble_bits() + for i, e in enumerate(out_map): + if e[1] != chars: + out_map = out_map[i:] + break + out_map.reverse() + for i, e in enumerate(out_map): + if e[1] != chars: + out_map = out_map[i:] + break + out_map.reverse() + return self.__class__.assemble_bits(out_map) + + def replace(self, old: str, new: Union[str, "Text"], count=None) -> "Text": + if not (indexes := self.find_all(old)): + return self.clone() + if count and count > 0: + indexes = indexes[:count] + old_len = len(old) + new_len = len(new) + other = self.clone() + markup_idx_map = self.disassemble_bits() + other_map = other.disassemble_bits() + + for idx in reversed(indexes): + final_markup = markup_idx_map[idx + old_len][0] + diff = abs(old_len - new_len) + replace_chars = min(new_len, old_len) + # First, replace any characters that overlap. + for i in range(replace_chars): + other_map[idx + i] = (markup_idx_map[idx + i][0], new[i]) + if old_len == new_len: + pass # the nicest case. nothing else needs doing. + elif old_len > new_len: + # slightly complex. pop off remaining characters. + for i in range(diff): + deleted = other_map.pop(idx + new_len) + elif new_len > old_len: + # slightly complex. insert new characters. + for i in range(diff): + other_map.insert( + idx + old_len + i, (final_markup, new[old_len + i]) + ) + + return self.__class__.assemble_bits(other_map) + + def find_all(self, sub: str): + indexes = list() + start = 0 + while True: + start = self.plain.find(sub, start) + if start == -1: + return indexes + indexes.append(start) + start += len(sub) + + def scramble(self): + idx = self.disassemble_bits() + random.shuffle(idx) + return self.__class__.assemble_bits(idx) + + def reverse(self): + idx = self.disassemble_bits() + idx.reverse() + return self.__class__.assemble_bits(idx) + + @classmethod + def assemble_bits(cls, idx: List[Tuple[Optional[Union[str, MudStyle, None]], str]]): + out = cls() + for i, t in enumerate(idx): + s = [Span(0, 1, t[0])] + out.append_text(cls(text=t[1], spans=s)) + return out + + def style_at_index(self, offset: int) -> MudStyle: + if offset < 0: + offset = len(self) + offset + style = MudStyle.null() + for start, end, span_style in self._spans: + if end > offset >= start: + style = style + span_style + return style + + def disassemble_bits(self) -> List[Tuple[Optional[Union[str, MudStyle, None]], str]]: + idx = list() + for i, c in enumerate(self.plain): + idx.append((self.style_at_index(i), c)) + return idx + + def squish(self) -> "MudText": + """ + Removes leading and trailing whitespace, and coerces all internal whitespace sequences + into at most a single space. Returns the results. + """ + out = list() + matches = _RE_SQUISH.finditer(self.plain) + for match in matches: + out.append(self[match.start(): match.end()]) + return self.__class__(" ").join(out) + + def squish_spaces(self) -> "MudText": + """ + Like squish, but retains newlines and tabs. Just squishes spaces. + """ + out = list() + matches = _RE_NOTSPACE.finditer(self.plain) + for match in matches: + out.append(self[match.start(): match.end()]) + return self.__class__(" ").join(out) + + def serialize(self) -> dict: + def ser_style(style): + if isinstance(style, str): + style = MudStyle.parse(style) + if not isinstance(style, MudStyle): + style = MudStyle.upgrade(style) + return style.serialize() + + def ser_span(span): + if not span.style: + return None + return { + "start": span.start, + "end": span.end, + "style": ser_style(span.style), + } + + out = {"text": self.plain} + + if self.style: + out["style"] = ser_style(self.style) + + out_spans = [s for span in self.spans if (s := ser_span(span))] + + if out_spans: + out["spans"] = out_spans + + return out + + @classmethod + def deserialize(cls, data) -> "Text": + text = data.get("text", None) + if text is None: + return cls("") + style = data.get("style", None) + if style: + style = MudStyle(**style) + + spans = data.get("spans", None) + + if spans: + spans = [Span(s["start"], s["end"], MudStyle(**s["style"])) for s in spans] + + return cls(text=text, style=style, spans=spans) + + +DEFAULT_STYLES = dict() + + +def install(): + from rich import style, text, console, default_styles, themes, syntax, traceback + global DEFAULT_STYLES + style.Style = MudStyle + style.NULL_STYLE = MudStyle() + text.Text = MudText + console.Console = MudConsole + console.ConsoleOptions = MudConsoleOptions + + traceback.Style = MudStyle + syntax.Style = MudStyle + traceback.Text = MudText + syntax.Text = MudText + + for k, v in default_styles.DEFAULT_STYLES.items(): + DEFAULT_STYLES[k] = MudStyle.upgrade(v) + + for theme in syntax.RICH_SYNTAX_THEMES.values(): + for k, v in theme.items(): + if isinstance(v, OLD_STYLE): + theme[k] = MudStyle.upgrade(v) + + default_styles.DEFAULT_STYLES = DEFAULT_STYLES + themes.DEFAULT = themes.Theme(DEFAULT_STYLES) \ No newline at end of file diff --git a/evennia/web/admin/objects.py b/evennia/web/admin/objects.py index a705fa4589..da73a4931b 100644 --- a/evennia/web/admin/objects.py +++ b/evennia/web/admin/objects.py @@ -319,7 +319,7 @@ class ObjectAdmin(admin.ModelAdmin): if account: account.db._last_puppet = obj - account.add_character(obj) + account.add_character_to_playable_list(obj) if not obj.access(account, "puppet"): lock = obj.locks.get("puppet") lock += f" or pid({account.id})" diff --git a/evennia/web/website/tests.py b/evennia/web/website/tests.py index fc1073a589..fc635c4b16 100644 --- a/evennia/web/website/tests.py +++ b/evennia/web/website/tests.py @@ -35,8 +35,8 @@ class EvenniaWebTest(BaseEvenniaTest): super().setUp() # Add chars to account rosters - self.account.db._playable_characters = [self.char1] - self.account2.db._playable_characters = [self.char2] + self.account.add_character_to_playable_list(self.char1) + self.account2.add_character_to_playable_list(self.char2) for account in (self.account, self.account2): # Demote accounts to Player permissions @@ -44,15 +44,15 @@ class EvenniaWebTest(BaseEvenniaTest): account.permissions.remove("Developer") # Grant permissions to chars - for char in account.db._playable_characters: + for char in account.characters: char.locks.add("edit:id(%s) or perm(Admin)" % account.pk) char.locks.add("delete:id(%s) or perm(Admin)" % account.pk) char.locks.add("view:all()") def test_valid_chars(self): "Make sure account has playable characters" - self.assertTrue(self.char1 in self.account.db._playable_characters) - self.assertTrue(self.char2 in self.account2.db._playable_characters) + self.assertTrue(self.char1 in self.account.characters) + self.assertTrue(self.char2 in self.account2.characters) def get_kwargs(self): return {} @@ -220,7 +220,7 @@ class CharacterCreateView(EvenniaWebTest): @override_settings(MAX_NR_CHARACTERS=1) def test_valid_access_multisession_0(self): "Account1 with no characters should be able to create a new one" - self.account.db._playable_characters = [] + self.assertFalse(self.account.characters, "Account1 has characters but shouldn't!") # Login account self.login() @@ -233,9 +233,9 @@ class CharacterCreateView(EvenniaWebTest): # Make sure the character was actually created self.assertTrue( - len(self.account.db._playable_characters) == 1, + len(self.account.characters) == 1, "Account only has the following characters attributed to it: %s" - % self.account.db._playable_characters, + % self.account.characters, ) @override_settings(MAX_NR_CHARACTERS=5) @@ -252,9 +252,9 @@ class CharacterCreateView(EvenniaWebTest): # Make sure the character was actually created self.assertTrue( - len(self.account.db._playable_characters) > 1, + len(self.account.characters) > 1, "Account only has the following characters attributed to it: %s" - % self.account.db._playable_characters, + % self.account.characters, ) @@ -352,7 +352,7 @@ class CharacterDeleteView(EvenniaWebTest): # Make sure it deleted self.assertFalse( - self.char1 in self.account.db._playable_characters, + self.char1 in self.account.characters, "Char1 is still in Account playable characters list.", ) diff --git a/pyproject.toml b/pyproject.toml index 9c74ce2cf5..ab4353a107 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,7 @@ dependencies = [ "black >= 22.6", "isort >= 5.10", "parameterized ==0.8.1", + "rich >= 13.3.5, ] [project.optional-dependencies]