Merge branch 'main' of github.com:evennia/evennia

This commit is contained in:
Griatch 2025-11-19 18:45:07 +01:00
commit 20dcb19776
9 changed files with 286 additions and 32 deletions

View file

@ -61,10 +61,10 @@ This upgrade requires running `evennia migrate` on your existing database
- Feat (backwards incompatible): RUN MIGRATIONS (`evennia migrate`): Now requiring Django 5.1 (Griatch)
- Feat (backwards incompatible): Drop support and testing for Python 3.10 (Griatch)
- [Feat][pull3719]: Support Python 3.13. (0xDEADFED5)
- [Feat][pull3719]: Support Python 3.13. (electroglyph)
- [Feat][pull3633]: Default object's default descs are now taken from a `default_description`
class variable instead of the `desc` Attribute always being set (count-infinity)
- [Feat][pull3718]: Remove twistd.bat creation for Windows, should not be needed anymore (0xDEADFED5)
- [Feat][pull3718]: Remove twistd.bat creation for Windows, should not be needed anymore (electroglyph)
- [Feat][pull3756]: Updated German translation (JohnFi)
- [Feat][pull3757]: Add more i18n strings to `DefaultObject` for easier translation (JohnFi)
- [Feat][pull3783]: Support users of `ruff` linter by adding compatible config in `pyproject.toml` (jaborsh)
@ -80,8 +80,8 @@ This upgrade requires running `evennia migrate` on your existing database
- [Fix][pull3690]: In searches, allow special 'here' and 'me' keywords only be valid queries
unless current location and/or caller is in valid search candidates respectively (InspectorCaracal)
- [Fix][pull3694]: Funcparser swallowing rest of line after a `\`-escape (count-infinity)
- [Fix][pull3705]: Properly serialize `IntFlag` enum types (0xDEADFED5)
- [Fix][pull3707]: Correct links in `about` command (0xDEADFED5)
- [Fix][pull3705]: Properly serialize `IntFlag` enum types (electroglyph)
- [Fix][pull3707]: Correct links in `about` command (electroglyph)
- [Fix][pull3710]: Clean reduntant session clearin in `at_server_cold_start` (InspectorCaracal)
- [Fix][pull3711]: Usability improvements in the Discord integration (InspectorCaracal)
- [Fix][pull3721]: Avoid loading cmdsets that don't need to be checked, avoiding
@ -97,7 +97,7 @@ This upgrade requires running `evennia migrate` on your existing database
- [Fix][pull3743]: Log full stack trace on failed object creation (aMiss-aWry)
- [Fix][pull3747]: TutorialWorld bridge-room didn't correctly randomize weather effects (SpyrosRoum)
- [Fix][pull3765]: Storing TickerHandler `store_key` in a db attribute would not
work correctly (0xDEADFED5)
work correctly (electroglyph)
- [Fix][pull3753]: Make sure `AttributeProperty`s are initialized with default values also in parent class (JohnFi)
- [Fix][pull3751]: The `access` and `inventory` commands would traceback if run on a character without an Account (EliasWatson)
- [Fix][pull3768]: Make sure the `CmdCopy` command copies object categories,
@ -112,7 +112,7 @@ This upgrade requires running `evennia migrate` on your existing database
it caused an OnDemandHandler save error on reload. Will now clean up on save. (Griatch)
used as the task's category (Griatch)
- Fix: Correct aws contrib's use of legacy django string utils (Griatch)
- [Docs]: Fixes from InspectorCaracal, Griatch, ChrisLR, JohnFi, 0xDEADFED5, jaborsh, Problematic, BlaneWins
- [Docs]: Fixes from InspectorCaracal, Griatch, ChrisLR, JohnFi, electroglyph, jaborsh, Problematic, BlaneWins
[pull3633]: https://github.com/evennia/evennia/pull/3633
[pull3677]: https://github.com/evennia/evennia/pull/3677
@ -234,7 +234,7 @@ Sep 29, 2024
- Feat: Support `scripts key:typeclass` to create global scripts
with dynamic keys (rather than just relying on typeclass' key) (Griatch)
- [Feat][pull3595]: Tweak Sqlite3 PRAGMAs for better performance (0xDEADFED5)
- [Feat][pull3595]: Tweak Sqlite3 PRAGMAs for better performance (electroglyph)
- Feat: Make Sqlite3 PRAGMAs configurable via settings (Griatch)
- [Feat][pull3592]: Revised German locationlization ('Du' instead of 'Sie',
cleanup) (Drakon72)
@ -243,7 +243,7 @@ with dynamic keys (rather than just relying on typeclass' key) (Griatch)
- [Feat][pull3588]: New `DefaultObject` hooks: `at_object_post_creation`, called once after
first creation but after any prototypes have been applied, and
`at_object_post_spawn(prototype)`, called only after creation/update with a prototype (InspectorCaracal)
- [Fix][pull3594]: Update/clean some Evennia dependencies (0xDEADFED5)
- [Fix][pull3594]: Update/clean some Evennia dependencies (electroglyph)
- [Fix][issue3556]: Better error if trying to treat ObjectDB as a typeclass (Griatch)
- [Fix][issue3590]: Make `examine` command properly show `strattr` type
Attribute values (Griatch)
@ -257,7 +257,7 @@ did not add it to the handler's object (Griatch)
- [Fix][pull3605]: Correctly pass node kwargs through `@list_node` decorated evmenu nodes
(InspectorCaracal)
- [Fix][pull3597]: Address timing issue for testing `new_task_waiting_input `on
Windows (0xDEADFED5)
Windows (electroglyph)
- [Fix][pull3611]: Fix and update for Reports contrib (InspectorCaracal)
- [Fix][pull3625]: Lycanthropy tutorial page had some issues (feyrkh)
- [Fix][pull3622]: Fix for examine command tracebacking with strvalue error
@ -303,10 +303,10 @@ Aug 11, 2024
- [Feat][pull3531]: New contrib; `in-game reports` for handling user reports,
bugs etc in-game (InspectorCaracal)
- [Feat][pull3586]: Add ANSI color support `|U`, `|I`, `|i`, `|s`, `|S` for
underline reset, italic/reset and strikethrough/reset (0xDEADFED5)
underline reset, italic/reset and strikethrough/reset (electroglyph)
- Feat: Add `Trait.traithandler` back-reference so custom Traits from the Traits
contrib can find and reference other Traits. (Griatch)
- [Feat][pull3582]: Add true-color parsing/fallback for ANSIString (0xDEADFED5)
- [Feat][pull3582]: Add true-color parsing/fallback for ANSIString (electroglyph)
- [Fix][pull3571]: Better visual display of partial multimatch search results
(InspectorCaracal)
- [Fix][issue3378]: Prototype 'alias' key was not properly homogenized to a list
@ -316,8 +316,8 @@ underline reset, italic/reset and strikethrough/reset (0xDEADFED5)
- [Fix][pull3585]: `TagCmd.switch_options` was misnamed (erratic-pattern)
- [Fix][pull3580]: Fix typo that made `find/loc` show the wrong dbref in result (erratic-pattern)
- [Fix][pull3589]: Fix regex escaping in `utils.py` for future Python versions (hhsiao)
- [Docs]: Add True-color description for Colors documentation (0xDEADFED5)
- [Docs]: Doc fixes (Griatch, InspectorCaracal, 0xDEADFED5)
- [Docs]: Add True-color description for Colors documentation (electroglyph)
- [Docs]: Doc fixes (Griatch, InspectorCaracal, electroglyph)
[pull3585]: https://github.com/evennia/evennia/pull/3585
[pull3580]: https://github.com/evennia/evennia/pull/3580

View file

@ -19,6 +19,7 @@ with [discussion forums][group] and a [discord server][chat] to help and support
pip install evennia
(windows users once: py -m evennia)
(note: Windows users with multiple Python versions should prefer `py -3.11` instead of `python` when creating virtual environments)
evennia --init mygame
cd mygame
evennia migrate

View file

@ -685,6 +685,11 @@ class ObjectDBManager(TypedObjectManager):
"or the setting is malformed."
)
# db_key has NOT NULL constraint, convert None to empty string.
# at_first_save() will convert empty string to #dbref
if key is None:
key = ""
# create new instance
new_object = typeclass(
db_key=key,

View file

@ -244,6 +244,20 @@ class DefaultObjectTest(BaseEvenniaTest):
class TestObjectManager(BaseEvenniaTest):
"Test object manager methods"
def test_create_object_with_none_key(self):
"""Test that create_object() handles key=None and key="" correctly."""
# Test with key=None - should convert to "" and then to #dbref
obj_none = ObjectDB.objects.create_object(key=None, location=self.room1)
self.assertIsNotNone(obj_none)
self.assertEqual(obj_none.key, f"#{obj_none.id}")
obj_none.delete()
# Test with key="" - should convert to #dbref
obj_empty = ObjectDB.objects.create_object(key="", location=self.room1)
self.assertIsNotNone(obj_empty)
self.assertEqual(obj_empty.key, f"#{obj_empty.id}")
obj_empty.delete()
def test_get_object_with_account(self):
query = ObjectDB.objects.get_object_with_account("TestAccount").first()
self.assertEqual(query, self.char1)

View file

@ -187,7 +187,7 @@ def homogenize_prototype(prototype, custom_keys=None):
"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_locks"] = homogenized.get("prototype_locks", _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

View file

@ -402,6 +402,39 @@ class TestProtLib(BaseEvenniaTest):
match = protlib.search_prototype(self.prot["prototype_key"].upper())
self.assertEqual(match, [self.prot])
def test_homogenize_prototype_locks_preserved(self):
"""Test that homogenize_prototype preserves custom prototype_locks. (Bug 3828)"""
from evennia.prototypes.prototypes import _PROTOTYPE_FALLBACK_LOCK
prot_with_locks = {
"prototype_key": "test_prot_with_locks",
"typeclass": "evennia.objects.objects.DefaultObject",
"prototype_locks": "spawn:perm(Builder);edit:perm(Admin)",
}
homogenized = protlib.homogenize_prototype(prot_with_locks)
self.assertEqual(
homogenized["prototype_locks"],
"spawn:perm(Builder);edit:perm(Admin)",
)
self.assertNotEqual(
homogenized["prototype_locks"],
_PROTOTYPE_FALLBACK_LOCK,
)
def test_homogenize_prototype_locks_default_fallback(self):
"""Test that homogenize_prototype uses default when prototype_locks not provided."""
from evennia.prototypes.prototypes import _PROTOTYPE_FALLBACK_LOCK
prot_without_locks = {
"prototype_key": "test_prot_without_locks",
"typeclass": "evennia.objects.objects.DefaultObject",
}
homogenized = protlib.homogenize_prototype(prot_without_locks)
self.assertEqual(
homogenized["prototype_locks"],
_PROTOTYPE_FALLBACK_LOCK,
)
class TestProtFuncs(BaseEvenniaTest):
@override_settings(PROT_FUNC_MODULES=["evennia.prototypes.protfuncs"])
@ -1045,6 +1078,47 @@ class TestIssue2908(BaseEvenniaTest):
self.assertEqual(obj[0].location, self.room1)
class TestIssue3824(BaseEvenniaTest):
"""
Test that $obj, $objlist, $search, and $dbref callables work correctly when spawning prototypes.
Regression test for bug where 'prototype' kwarg was passed to search functions causing TypeError.
"""
def test_spawn_with_search_callables(self):
"""Test spawning prototype with $obj, $objlist, $search, and $dbref callables."""
# Setup: tag some objects for searching
self.room1.tags.add("test_location", category="zone")
self.room2.tags.add("test_location", category="zone")
self.obj1.tags.add("test_item", category="item_type")
# Create prototype using all search callables
prot = {
"prototype_key": "test_search_callables",
"typeclass": "evennia.objects.objects.DefaultObject",
"key": "test object",
"attr_obj": f"$obj({self.obj1.dbref})",
"attr_search": "$search(Char)",
"attr_objlist": "$objlist(test_location, category=zone, type=tag)",
"attr_dbref": f"$dbref({self.obj1.dbref})",
}
# This should not raise TypeError about 'prototype' kwarg
objs = spawner.spawn(prot, caller=self.char1)
self.assertEqual(len(objs), 1)
obj = objs[0]
# Verify all search callables worked correctly
self.assertEqual(obj.db.attr_obj, self.obj1)
self.assertEqual(obj.db.attr_search, self.char1)
self.assertEqual(obj.db.attr_dbref, self.obj1)
# attr_objlist should be a list or list-like object with 2 rooms
objlist = obj.db.attr_objlist
self.assertEqual(len(objlist), 2)
self.assertIn(self.room1, objlist)
self.assertIn(self.room2, objlist)
class TestIssue3101(EvenniaCommandTest):
"""
Spawning and using create_object should store the same `typeclass_path` if using

View file

@ -703,12 +703,17 @@ def _transform(func_name):
def wrapped(self, *args, **kwargs):
replacement_string = _query_super(func_name)(self, *args, **kwargs)
# Convert to sets for O(1) membership testing
code_indexes_set = set(self._code_indexes)
char_indexes_set = set(self._char_indexes)
to_string = []
char_counter = 0
for index in range(0, len(self._raw_string)):
if index in self._code_indexes:
if index in code_indexes_set:
to_string.append(self._raw_string[index])
elif index in self._char_indexes:
elif index in char_indexes_set:
to_string.append(replacement_string[char_counter])
char_counter += 1
return ANSIString(
@ -1028,10 +1033,12 @@ class ANSIString(str, metaclass=ANSIMeta):
return ANSIString("")
last_mark = slice_indexes[0]
# Check between the slice intervals for escape sequences.
# Convert to set for O(1) membership testing
code_indexes_set = set(self._code_indexes)
i = None
for i in slice_indexes[1:]:
for index in range(last_mark, i):
if index in self._code_indexes:
if index in code_indexes_set:
string += self._raw_string[index]
last_mark = i
try:
@ -1065,15 +1072,18 @@ class ANSIString(str, metaclass=ANSIMeta):
append_tail = self._get_interleving(item + 1)
else:
append_tail = ""
item = self._char_indexes[item]
clean = self._raw_string[item]
result = ""
# Get the character they're after, and replay all escape sequences
# previous to it.
for index in range(0, item + 1):
if index in self._code_indexes:
result += self._raw_string[index]
char_pos = self._char_indexes[item]
clean = self._raw_string[char_pos]
code_indexes_set = set(self._code_indexes)
result_chars = [
self._raw_string[index] for index in range(0, char_pos + 1) if index in code_indexes_set
]
result = "".join(result_chars)
return ANSIString(result + clean + append_tail, decoded=True)
def clean(self):
@ -1153,7 +1163,9 @@ class ANSIString(str, metaclass=ANSIMeta):
# Plain string, no ANSI codes.
return code_indexes, list(range(0, len(self._raw_string)))
# all indexes not occupied by ansi codes are normal characters
char_indexes = [i for i in range(len(self._raw_string)) if i not in code_indexes]
code_indexes_set = set(code_indexes)
char_indexes = [i for i in range(len(self._raw_string)) if i not in code_indexes_set]
return code_indexes, char_indexes
def _get_interleving(self, index):
@ -1166,12 +1178,17 @@ class ANSIString(str, metaclass=ANSIMeta):
index = self._char_indexes[index - 1]
except IndexError:
return ""
# Convert to sets for O(1) membership testing
char_indexes_set = set(self._char_indexes)
code_indexes_set = set(self._code_indexes)
s = ""
while True:
index += 1
if index in self._char_indexes:
if index in char_indexes_set:
break
elif index in self._code_indexes:
elif index in code_indexes_set:
s += self._raw_string[index]
else:
break

View file

@ -1131,12 +1131,12 @@ def funcparser_callable_search(*args, caller=None, access="control", **kwargs):
- "$search(beach, category=outdoors, type=tag)
"""
# clean out funcparser-specific kwargs so we can use the kwargs for
# clean out funcparser and protfunc_parser-specific kwargs so we can use the kwargs for
# searching
search_kwargs = {
key: value
for key, value in kwargs.items()
if key not in ("funcparser", "raise_errors", "type", "return_list")
if key not in ("funcparser", "raise_errors", "type", "return_list", "prototype")
}
return_list = str(kwargs.pop("return_list", "false")).lower() == "true"

View file

@ -8,7 +8,16 @@ Test of the ANSI parsing and ANSIStrings.
from django.test import TestCase
from evennia.utils.ansi import ANSIString as AN
from evennia.utils.ansi import (
ANSIString as AN,
ANSI_RED,
ANSI_CYAN,
ANSI_YELLOW,
ANSI_GREEN,
ANSI_BLUE,
ANSI_HILITE,
ANSI_NORMAL,
)
class TestANSIString(TestCase):
@ -20,7 +29,9 @@ class TestANSIString(TestCase):
self.example_raw = "|relectric |cboogaloo|n"
self.example_ansi = AN(self.example_raw)
self.example_str = "electric boogaloo"
self.example_output = "\x1b[1m\x1b[31melectric \x1b[1m\x1b[36mboogaloo\x1b[0m"
self.example_output = (
f"{ANSI_HILITE}{ANSI_RED}electric {ANSI_HILITE}{ANSI_CYAN}boogaloo{ANSI_NORMAL}"
)
def test_length(self):
self.assertEqual(len(self.example_ansi), 17)
@ -52,3 +63,135 @@ class TestANSIString(TestCase):
self.assertEqual(split2, split3, "Split 2 and 3 differ")
self.assertEqual(split1, split2, "Split 1 and 2 differ")
self.assertEqual(split1, split3, "Split 1 and 3 differ")
def test_getitem_index_access(self):
"""Test individual character access via indexing"""
# Test accessing individual characters
self.assertEqual(self.example_ansi[0].clean(), "e")
self.assertEqual(self.example_ansi[9].clean(), "b")
self.assertEqual(self.example_ansi[-1].clean(), "o")
self.assertEqual(self.example_ansi[-2].clean(), "o")
# Verify ANSI codes are preserved when accessing characters
first_char = self.example_ansi[0]
self.assertTrue(isinstance(first_char, AN))
# First character should have red color code
self.assertIn(ANSI_RED, first_char.raw())
# Test character at color boundary (first character after color change)
ninth_char = self.example_ansi[9]
self.assertEqual(ninth_char.clean(), "b")
# Should have cyan color code
self.assertIn(ANSI_CYAN, ninth_char.raw())
def test_getitem_slice_access(self):
"""Test slice access"""
# Test basic slicing
substring = self.example_ansi[0:8]
self.assertEqual(substring.clean(), "electric")
self.assertTrue(isinstance(substring, AN))
# Test slicing with step
substring2 = self.example_ansi[9:17]
self.assertEqual(substring2.clean(), "boogaloo")
# Test negative indices
last_three = self.example_ansi[-3:]
self.assertEqual(last_three.clean(), "loo")
# Verify ANSI codes are preserved in slices
first_word = self.example_ansi[0:8]
self.assertIn(ANSI_RED, first_word.raw())
def test_getitem_edge_cases(self):
"""Test edge cases for indexing"""
# Test with string with no ANSI codes
plain = AN("plain text")
self.assertEqual(plain[0].clean(), "p")
self.assertEqual(plain[6].clean(), "t")
# Test with single character
single = AN("|rX|n")
self.assertEqual(len(single), 1)
self.assertEqual(single[0].clean(), "X")
# Test IndexError
with self.assertRaises(IndexError):
_ = self.example_ansi[100]
def test_upper_method(self):
"""Test upper() method"""
# Test basic upper with ANSI codes
result = self.example_ansi.upper()
self.assertEqual(result.clean(), "ELECTRIC BOOGALOO")
self.assertTrue(isinstance(result, AN))
# Verify ANSI codes are preserved
self.assertIn(ANSI_RED, result.raw())
self.assertIn(ANSI_CYAN, result.raw())
# Test with mixed case
mixed = AN("|rHeLLo |cWoRLd|n")
self.assertEqual(mixed.upper().clean(), "HELLO WORLD")
def test_lower_method(self):
"""Test lower() method"""
# Test basic lower with ANSI codes
upper_ansi = AN("|rELECTRIC |cBOOGALOO|n")
result = upper_ansi.lower()
self.assertEqual(result.clean(), "electric boogaloo")
self.assertTrue(isinstance(result, AN))
# Verify ANSI codes are preserved
self.assertIn(ANSI_RED, result.raw())
self.assertIn(ANSI_CYAN, result.raw())
def test_capitalize_method(self):
"""Test capitalize() method"""
# Test basic capitalize with ANSI codes
lower_ansi = AN("|relectric |cboogaloo|n")
result = lower_ansi.capitalize()
self.assertEqual(result.clean(), "Electric boogaloo")
self.assertTrue(isinstance(result, AN))
# Verify ANSI codes are preserved
self.assertIn(ANSI_RED, result.raw())
def test_swapcase_method(self):
"""Test swapcase() method"""
# Test basic swapcase with ANSI codes
mixed = AN("|rElEcTrIc |cBoOgAlOo|n")
result = mixed.swapcase()
self.assertEqual(result.clean(), "eLeCtRiC bOoGaLoO")
self.assertTrue(isinstance(result, AN))
# Verify ANSI codes are preserved
self.assertIn(ANSI_RED, result.raw())
self.assertIn(ANSI_CYAN, result.raw())
def test_transform_with_dense_ansi(self):
"""Test string transformation with ANSI codes between every character"""
# Simulate rainbow text with ANSI between each character
dense = AN("|rh|ce|yl|gl|bo|n")
self.assertEqual(dense.clean(), "hello")
# Test upper preserves all ANSI codes
upper_dense = dense.upper()
self.assertEqual(upper_dense.clean(), "HELLO")
self.assertTrue(isinstance(upper_dense, AN))
# Verify all color codes are still present
raw = upper_dense.raw()
self.assertIn(ANSI_RED, raw)
self.assertIn(ANSI_CYAN, raw)
self.assertIn(ANSI_YELLOW, raw)
self.assertIn(ANSI_GREEN, raw)
self.assertIn(ANSI_BLUE, raw)
def test_transform_without_ansi(self):
"""Test string transformation on plain strings"""
plain = AN("hello world")
self.assertEqual(plain.upper().clean(), "HELLO WORLD")
self.assertEqual(plain.lower().clean(), "hello world")
self.assertEqual(plain.capitalize().clean(), "Hello world")