From cbe3d4c738efb7e94ff9633fc1afb2d5f24c954c Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 9 Mar 2024 20:02:18 +0100 Subject: [PATCH] Make numbered_names use get_display_name; make dbref display separate method --- CHANGELOG.md | 16 ++++- evennia/commands/default/general.py | 10 ++- evennia/commands/default/tests.py | 63 +++++++------------ .../contrib/full_systems/evscaperoom/tests.py | 2 +- .../contrib/game_systems/clothing/clothing.py | 26 +++++--- .../contrib/game_systems/clothing/tests.py | 12 +++- .../contrib/game_systems/containers/tests.py | 15 ++++- evennia/contrib/grid/extended_room/tests.py | 41 ++++++------ evennia/contrib/rpg/rpsystem/rpsystem.py | 26 +++----- evennia/contrib/rpg/rpsystem/tests.py | 9 ++- evennia/objects/models.py | 1 - evennia/objects/objects.py | 50 ++++++++++++--- evennia/utils/tests/test_utils.py | 53 +++++++++++++++- evennia/utils/utils.py | 40 +++++++++++- 14 files changed, 242 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41dc92cb29..91ab4540b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,30 @@ ## Evennia Main branch +- Feature: *Backwards incompatible*: `DefaultObject.get_numbered_name` now gets object's + name via `.get_display_name` for better compatibility with recog systems. +- Feature: *Backwards incompatible*: Removed the (#dbref) display from + `DefaultObject.get_display_name`, instead using new `.get_extra_display_name_info` + method for getting this info. The Object's display template was extended for + optionally adding this information. This makes showing extra object info to + admins an explicit action and opens up `get_display_name` for general use. - Feature: Add `ON_DEMAND_HANDLER.set_dt(key, category, dt)` and `.set_stage(key, category, stage)` to allow manual tweaking of task timings, for example for a spell speeding a plant's growth (Griatch) - Feature: Add `use_assertequal` kwarg to the `EvenniaCommandTestMixin` testing class; this uses django's `assertEqual` over the default more lenient checker, which can be useful for testing table whitespace (Griatch) +- Feature: New `utils.group_objects_by_key_and_desc` for grouping a list of + objects based on the visible key and desc. Useful for inventory listings (Griatch) +- Feature: Add `DefaultObject.get_numbered_name` `return_string` bool kwarg, for only + returning singular/plural based on count instead of a tuple with both (Griatch) +- Fix: `DefaultObject.get_numbered_name` used `.name` instead of + `.get_display_name` which broke recog systems. May lead to object's #dbref + will show for admins in some more places (Griatch) - [Fix][pull3420]: Refactor Clothing contrib's inventory command align with Evennia core's version (michaelfaith84, Griatch) - Fix: Resolve a bug when loading on-demand-handler data from database (Griatch) -- Doc fixes (iLPdev, Griatch) +- Doc fixes (iLPdev, Griatch, CloudKeeper) [pull3420]: https://github.com/evennia/evennia/pull/3420 diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index 31a5ccedb9..4a65b63b2d 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -3,9 +3,8 @@ General Character commands usually available to all characters """ import re -from django.conf import settings - import evennia +from django.conf import settings from evennia.typeclasses.attributes import NickTemplateInvalid from evennia.utils import utils @@ -370,11 +369,10 @@ class CmdInventory(COMMAND_DEFAULT_CLASS): from evennia.utils.ansi import raw as raw_ansi table = self.styled_table(border="header") - for item in items: - singular, _ = item.get_numbered_name(1, self.caller) + for key, desc, _ in utils.group_objects_by_key_and_desc(items, caller=self.caller): table.add_row( - f"|C{singular}|n", - "{}|n".format(utils.crop(raw_ansi(item.db.desc or ""), width=50) or ""), + f"|C{key}|n", + "{}|n".format(utils.crop(raw_ansi(desc or ""), width=50) or ""), ) string = f"|wYou are carrying:\n{table}" self.msg(text=(string, {"type": "inventory"})) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 160d314458..8977c890d3 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -14,13 +14,10 @@ main test suite started with import datetime from unittest.mock import MagicMock, Mock, patch +import evennia from anything import Anything from django.conf import settings from django.test import override_settings -from parameterized import parameterized -from twisted.internet import task - -import evennia from evennia import ( DefaultCharacter, DefaultExit, @@ -32,14 +29,7 @@ from evennia import ( from evennia.commands import cmdparser from evennia.commands.cmdset import CmdSet from evennia.commands.command import Command, InterruptCommand -from evennia.commands.default import ( - account, - admin, - batchprocess, - building, - comms, - general, -) +from evennia.commands.default import account, admin, batchprocess, building, comms, general from evennia.commands.default import help as help_module from evennia.commands.default import syscommands, system, unloggedin from evennia.commands.default.cmdset_character import CharacterCmdSet @@ -48,6 +38,8 @@ from evennia.prototypes import prototypes as protlib from evennia.utils import create, gametime, utils from evennia.utils.test_resources import BaseEvenniaCommandTest # noqa from evennia.utils.test_resources import BaseEvenniaTest, EvenniaCommandTest +from parameterized import parameterized +from twisted.internet import task # ------------------------------------------------------------ # Command testing @@ -116,13 +108,13 @@ class TestGeneral(BaseEvenniaCommandTest): self.call(general.CmdNick(), "/list", "Defined Nicks:") def test_get_and_drop(self): - self.call(general.CmdGet(), "Obj", "You pick up an Obj.") - self.call(general.CmdDrop(), "Obj", "You drop an Obj.") + self.call(general.CmdGet(), "Obj", "You pick up an Obj") + self.call(general.CmdDrop(), "Obj", "You drop an Obj") def test_give(self): self.call(general.CmdGive(), "Obj to Char2", "You aren't carrying Obj.") self.call(general.CmdGive(), "Obj = Char2", "You aren't carrying Obj.") - self.call(general.CmdGet(), "Obj", "You pick up an Obj.") + self.call(general.CmdGet(), "Obj", "You pick up an Obj") self.call(general.CmdGive(), "Obj to Char2", "You give") self.call(general.CmdGive(), "Obj = Char", "You give", caller=self.char2) @@ -569,7 +561,7 @@ class TestAdmin(BaseEvenniaCommandTest): self.call( admin.CmdForce(), "Char2=say test", - 'Char2(#{}) says, "test"|You have forced Char2 to: say test'.format(cid), + 'Char2 says, "test"|You have forced Char2 to: say test', ) @@ -781,17 +773,14 @@ class TestBuilding(BaseEvenniaCommandTest): self.call(building.CmdExamine(), "*TestAccount") def test_set_obj_alias(self): - oid = self.obj1.id self.call(building.CmdSetObjAlias(), "Obj =", "Cleared aliases from Obj") self.call( - building.CmdSetObjAlias(), - "Obj = TestObj1b", - "Alias(es) for 'Obj(#{})' set to 'testobj1b'.".format(oid), + building.CmdSetObjAlias(), "Obj = TestObj1b", "Alias(es) for 'Obj' set to 'testobj1b'." ) self.call(building.CmdSetObjAlias(), "", "Usage: ") self.call(building.CmdSetObjAlias(), "NotFound =", "Could not find 'NotFound'.") - self.call(building.CmdSetObjAlias(), "Obj", "Aliases for Obj(#{}): 'testobj1b'".format(oid)) + self.call(building.CmdSetObjAlias(), "Obj", "Aliases for Obj: 'testobj1b'") self.call(building.CmdSetObjAlias(), "Obj2 =", "Cleared aliases from Obj2") self.call(building.CmdSetObjAlias(), "Obj2 =", "No aliases to clear.") @@ -1228,9 +1217,7 @@ class TestBuilding(BaseEvenniaCommandTest): def test_desc(self): oid = self.obj2.id - self.call( - building.CmdDesc(), "Obj2=TestDesc", "The description was set on Obj2(#{}).".format(oid) - ) + self.call(building.CmdDesc(), "Obj2=TestDesc", "The description was set on Obj2.") self.call(building.CmdDesc(), "", "Usage: ") with patch("evennia.commands.default.building.EvEditor") as mock_ed: @@ -1251,7 +1238,7 @@ class TestBuilding(BaseEvenniaCommandTest): oid = self.obj2.id o2d = self.obj2.db.desc r1d = self.room1.db.desc - self.call(building.CmdDesc(), "Obj2=", "The description was set on Obj2(#{}).".format(oid)) + self.call(building.CmdDesc(), "Obj2=", "The description was set on Obj2.") assert self.obj2.db.desc == "" and self.obj2.db.desc != o2d assert self.room1.db.desc == r1d @@ -1260,7 +1247,7 @@ class TestBuilding(BaseEvenniaCommandTest): rid = self.room1.id o2d = self.obj2.db.desc r1d = self.room1.db.desc - self.call(building.CmdDesc(), "Obj2", "The description was set on Room(#{}).".format(rid)) + self.call(building.CmdDesc(), "Obj2", "The description was set on Room.") assert self.obj2.db.desc == o2d assert self.room1.db.desc == "Obj2" and self.room1.db.desc != r1d @@ -1283,16 +1270,11 @@ class TestBuilding(BaseEvenniaCommandTest): building.CmdDestroy(), settings.DEFAULT_HOME, "You are trying to delete" ) # DEFAULT_HOME should not be deleted self.char2.location = self.room2 - charid = self.char2.id - room1id = self.room1.id - room2id = self.room2.id self.call( building.CmdDestroy(), self.room2.dbref, - "Char2(#{}) arrives to Room(#{}) from Room2(#{}).|Room2 was destroyed.".format( - charid, room1id, room2id - ), - ) + "Char2 arrives to Room from Room2.|Room2 was destroyed.", + ), building.CmdDestroy.confirm = confirm def test_destroy_sequence(self): @@ -1640,9 +1622,6 @@ class TestBuilding(BaseEvenniaCommandTest): self.assertFalse(script3.pk) def test_teleport(self): - oid = self.obj1.id - rid = self.room1.id - rid2 = self.room2.id self.call(building.CmdTeleport(), "", "Usage: ") self.call(building.CmdTeleport(), "Obj = Room", "Obj is already at Room.") self.call( @@ -1653,9 +1632,7 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdTeleport(), "Obj = Room2", - "Obj(#{}) is leaving Room(#{}), heading for Room2(#{}).|Teleported Obj -> Room2.".format( - oid, rid, rid2 - ), + "Obj is leaving Room, heading for Room2.|Teleported Obj -> Room2.", ) self.call(building.CmdTeleport(), "NotFound = Room", "Could not find 'NotFound'.") self.call( @@ -1663,7 +1640,7 @@ class TestBuilding(BaseEvenniaCommandTest): ) self.call(building.CmdTeleport(), "/tonone Obj2", "Teleported Obj2 -> None-location.") - self.call(building.CmdTeleport(), "/quiet Room2", "Room2(#{})".format(rid2)) + self.call(building.CmdTeleport(), "/quiet Room2", "Room2") self.call( building.CmdTeleport(), "/t", # /t switch is abbreviated form of /tonone @@ -1777,7 +1754,8 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdSpawn(), "{'prototype_key':'GOBLIN', 'typeclass':'evennia.objects.objects.DefaultCharacter', " - "'key':'goblin', 'location':'%s'}" % spawnLoc.dbref, + "'key':'goblin', 'location':'%s'}" + % spawnLoc.dbref, "Spawned goblin", ) goblin = get_object(self, "goblin") @@ -1825,7 +1803,8 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdSpawn(), "/noloc {'prototype_parent':'TESTBALL', 'key': 'Ball', 'prototype_key': 'foo'," - " 'location':'%s'}" % spawnLoc.dbref, + " 'location':'%s'}" + % spawnLoc.dbref, "Spawned Ball", ) ball = get_object(self, "Ball") diff --git a/evennia/contrib/full_systems/evscaperoom/tests.py b/evennia/contrib/full_systems/evscaperoom/tests.py index 92c214d785..1b5cd75e0a 100644 --- a/evennia/contrib/full_systems/evscaperoom/tests.py +++ b/evennia/contrib/full_systems/evscaperoom/tests.py @@ -183,7 +183,7 @@ class TestEvscaperoomCommands(BaseEvenniaCommandTest): self.call( commands.CmdEmote(), "/me smiles to /obj", - f"Char(#{self.char1.id}) smiles to Obj(#{self.obj1.id})", + f"Char smiles to Obj.", ) def test_focus_interaction(self): diff --git a/evennia/contrib/game_systems/clothing/clothing.py b/evennia/contrib/game_systems/clothing/clothing.py index 6c53eeac6b..2c98b17f9f 100644 --- a/evennia/contrib/game_systems/clothing/clothing.py +++ b/evennia/contrib/game_systems/clothing/clothing.py @@ -77,7 +77,15 @@ from collections import defaultdict from django.conf import settings from evennia import DefaultCharacter, DefaultObject, default_cmds from evennia.commands.default.muxcommand import MuxCommand -from evennia.utils import at_search_result, crop, evtable, inherits_from, int2str, iter_to_str +from evennia.utils import ( + at_search_result, + crop, + evtable, + group_objects_by_key_and_desc, + inherits_from, + int2str, + iter_to_str, +) from evennia.utils.ansi import raw as raw_ansi # Options start here. @@ -660,11 +668,10 @@ class CmdInventory(MuxCommand): carried = [obj for obj in items if not obj.db.worn] carry_table = self.styled_table(border="header") - for item in carried: - singular, _ = item.get_numbered_name(1, self.caller) + for key, desc, _ in group_objects_by_key_and_desc(carried, caller=self.caller): carry_table.add_row( - f"{singular}|n", - "{}|n".format(crop(raw_ansi(item.db.desc or ""), width=50) or ""), + f"{key}|n", + "{}|n".format(crop(raw_ansi(desc or ""), width=50) or ""), ) message_list.extend( ["|wYou are carrying:|n", str(carry_table) if carry_table.nrows > 0 else " Nothing."] @@ -674,18 +681,17 @@ class CmdInventory(MuxCommand): worn = [obj for obj in items if obj.db.worn] wear_table = self.styled_table(border="header") - for item in worn: - singular, _ = item.get_numbered_name(1, self.caller) + for key, desc, _ in group_objects_by_key_and_desc(worn, caller=self.caller): wear_table.add_row( - f"{singular}|n", - "{}|n".format(crop(raw_ansi(item.db.desc or ""), width=50) or ""), + f"{key}|n", + "{}|n".format(crop(raw_ansi(desc or ""), width=50) or ""), ) message_list.extend( ["You are wearing:|n", str(wear_table) if wear_table.nrows > 0 else " Nothing."] ) # return the composite message - self.caller.msg("\n".join(message_list)) + self.caller.msg(text=("\n".join(message_list), {"type": "inventory"})) class ClothedCharacterCmdSet(default_cmds.CharacterCmdSet): diff --git a/evennia/contrib/game_systems/clothing/tests.py b/evennia/contrib/game_systems/clothing/tests.py index 637f703d69..8a2711c3d9 100644 --- a/evennia/contrib/game_systems/clothing/tests.py +++ b/evennia/contrib/game_systems/clothing/tests.py @@ -19,9 +19,11 @@ class TestClothingCmd(BaseEvenniaCommandTest): self.wearer.location = self.room # Make a test hat self.test_hat = create_object(clothing.ContribClothing, key="test hat") + self.test_hat.db.desc = "A test hat." self.test_hat.db.clothing_type = "hat" # Make a test scarf self.test_scarf = create_object(clothing.ContribClothing, key="test scarf") + self.test_scarf.db.desc = "A test scarf." self.test_scarf.db.clothing_type = "accessory" def test_clothingcommands(self): @@ -40,7 +42,10 @@ class TestClothingCmd(BaseEvenniaCommandTest): self.call( clothing.CmdInventory(), "", - "You are carrying:\n a test scarf \n a test hat \nYou are wearing:\n Nothing.", + ( + "You are carrying:\n a test hat A test hat. \n a test scarf A test" + " scarf. \nYou are wearing:\n Nothing." + ), caller=self.wearer, use_assertequal=True, ) @@ -71,7 +76,10 @@ class TestClothingCmd(BaseEvenniaCommandTest): self.call( clothing.CmdInventory(), "", - "You are carrying:\n Nothing.\nYou are wearing:\n a test scarf \n a test hat ", + ( + "You are carrying:\n Nothing.\nYou are wearing:\n a test hat A test hat. \n" + " a test scarf A test scarf. " + ), caller=self.wearer, use_assertequal=True, ) diff --git a/evennia/contrib/game_systems/containers/tests.py b/evennia/contrib/game_systems/containers/tests.py index 45769a59d6..1b9fe576a1 100644 --- a/evennia/contrib/game_systems/containers/tests.py +++ b/evennia/contrib/game_systems/containers/tests.py @@ -1,5 +1,6 @@ from evennia import create_object -from evennia.utils.test_resources import BaseEvenniaCommandTest, BaseEvenniaTest # noqa +from evennia.utils.test_resources import BaseEvenniaCommandTest # noqa +from evennia.utils.test_resources import BaseEvenniaTest from .containers import CmdContainerGet, CmdContainerLook, CmdPut, ContribContainer @@ -40,9 +41,17 @@ class TestContainerCmds(BaseEvenniaCommandTest): # get normally self.call(CmdContainerGet(), "Obj", "You pick up an Obj.") # put in the container - self.call(CmdPut(), "obj in box", "You put an Obj in a Box.") + self.call( + CmdPut(), + "obj in box", + "You put an Obj in a Box.", + ) # get from the container - self.call(CmdContainerGet(), "obj from box", "You get an Obj from a Box.") + self.call( + CmdContainerGet(), + "obj from box", + "You get an Obj from a Box.", + ) def test_locked_get_put(self): # lock container diff --git a/evennia/contrib/grid/extended_room/tests.py b/evennia/contrib/grid/extended_room/tests.py index bd54e0f1a5..2a8ed62256 100644 --- a/evennia/contrib/grid/extended_room/tests.py +++ b/evennia/contrib/grid/extended_room/tests.py @@ -6,11 +6,10 @@ Testing of ExtendedRoom contrib import datetime from django.conf import settings -from mock import Mock, patch -from parameterized import parameterized - from evennia import create_object from evennia.utils.test_resources import BaseEvenniaCommandTest, EvenniaTestCase +from mock import Mock, patch +from parameterized import parameterized from . import extended_room @@ -195,7 +194,7 @@ class TestExtendedRoomCommands(BaseEvenniaCommandTest): extended_room.CmdExtendedRoomDesc(), "", f""" -Room Room(#{self.room1.id}) Season: autumn. Time: afternoon. States: None +Room Room Season: autumn. Time: afternoon. States: None Room state (default) (active): Base room description. @@ -218,7 +217,7 @@ Base room description. extended_room.CmdExtendedRoomDesc(), "", f""" -Room Room(#{self.room1.id}) Season: autumn. Time: afternoon. States: None +Room Room Season: autumn. Time: afternoon. States: None Room state burning: Burning description. @@ -235,8 +234,10 @@ Base room description. self.call( extended_room.CmdExtendedRoomDesc(), "/del/burning/spring", - "The burning-description was deleted, if it existed.|The spring-description was" - " deleted, if it existed", + ( + "The burning-description was deleted, if it existed.|The spring-description was" + " deleted, if it existed" + ), ) # add autumn, which should be active self.call( @@ -248,7 +249,7 @@ Base room description. extended_room.CmdExtendedRoomDesc(), "", f""" -Room Room(#{self.room1.id}) Season: autumn. Time: afternoon. States: None +Room Room Season: autumn. Time: afternoon. States: None Room state autumn (active): Autumn description. @@ -285,8 +286,8 @@ test: Test detail. self.call( extended_room.CmdExtendedRoomDetail(), "", - f""" -The room Room(#{self.room1.id}) doesn't have any details. + """ +The room Room doesn't have any details. """.strip(), ) @@ -306,7 +307,7 @@ The room Room(#{self.room1.id}) doesn't have any details. self.call( extended_room.CmdExtendedRoomState(), "", - f"Room states (not counting automatic time/season) on Room(#{self.room1.id}):\n None", + "Room states (not counting automatic time/season) on Room:\n None", ) # add room states @@ -323,8 +324,7 @@ The room Room(#{self.room1.id}) doesn't have any details. self.call( extended_room.CmdExtendedRoomState(), "", - f"Room states (not counting automatic time/season) on Room(#{self.room1.id}):\n " - "'burning' and 'windy'", + f"Room states (not counting automatic time/season) on Room:\n 'burning' and 'windy'", ) # toggle windy self.call( @@ -335,8 +335,7 @@ The room Room(#{self.room1.id}) doesn't have any details. self.call( extended_room.CmdExtendedRoomState(), "", - f"Room states (not counting automatic time/season) on Room(#{self.room1.id}):\n " - "'burning'", + f"Room states (not counting automatic time/season) on Room:\n 'burning'", ) # add a autumn state and make sure we override it self.room1.add_desc("Autumn description.", room_state="autumn") @@ -387,13 +386,17 @@ The room Room(#{self.room1.id}) doesn't have any details. self.call( extended_room.CmdExtendedRoomLook(), "", - f"Room(#{self.room1.id})\nThis is a nice autumnal forest. The afternoon sun is" - " shining through the trees.", + ( + f"Room(#{self.room1.id})\nThis is a nice autumnal forest. The afternoon sun is" + " shining through the trees." + ), ) self.room1.add_room_state("burning") self.call( extended_room.CmdExtendedRoomLook(), "", - f"Room(#{self.room1.id})\nThis is a nice autumnal forest. The afternoon sun is" - " shining through the trees and this place is on fire!", + ( + f"Room(#{self.room1.id})\nThis is a nice autumnal forest. The afternoon sun is" + " shining through the trees and this place is on fire!" + ), ) diff --git a/evennia/contrib/rpg/rpsystem/rpsystem.py b/evennia/contrib/rpg/rpsystem/rpsystem.py index ae54ac8a52..fccef752ef 100644 --- a/evennia/contrib/rpg/rpsystem/rpsystem.py +++ b/evennia/contrib/rpg/rpsystem/rpsystem.py @@ -154,18 +154,12 @@ from string import punctuation import inflect from django.conf import settings - from evennia.commands.cmdset import CmdSet from evennia.commands.command import Command from evennia.objects.models import ObjectDB from evennia.objects.objects import DefaultCharacter, DefaultObject from evennia.utils import ansi, logger -from evennia.utils.utils import ( - iter_to_str, - lazy_property, - make_iter, - variable_from_module, -) +from evennia.utils.utils import iter_to_str, lazy_property, make_iter, variable_from_module _INFLECT = inflect.engine() @@ -1343,13 +1337,15 @@ class ContribRPObject(DefaultObject): # in eventual error reporting later (not their keys). Doing # it like this e.g. allows for use of the typeclass kwarg # limiter. - results.extend([obj for obj in search_obj(candidate.key) if obj not in results]) + results.extend( + [obj for obj in search_obj(candidate.key, **kwargs) if obj not in results] + ) if not results and is_builder: - # builders get a chance to search only by key+alias - results = search_obj(searchdata, candidates=candidates, **kwargs) + # builders get to do a global search by key+alias + results = search_obj(searchdata, **kwargs) else: - # global searches / #drefs end up here. Global searches are + # global searches with #drefs end up here. Global searches are # only done in code, so is controlled, #dbrefs are turned off # for non-Builders. results = search_obj(searchdata, **kwargs) @@ -1409,10 +1405,6 @@ class ContribRPObject(DefaultObject): # use own sdesc as a fallback sdesc = self.sdesc.get() - # add dbref is looker has control access and `noid` is not set - if self.access(looker, access_type="control") and not kwargs.get("noid", False): - sdesc = f"{sdesc}(#{self.id})" - return self.get_posed_sdesc(sdesc) if kwargs.get("pose", False) else sdesc def get_display_characters(self, looker, pose=True, **kwargs): @@ -1545,10 +1537,6 @@ class ContribRPCharacter(DefaultCharacter, ContribRPObject): # use own sdesc as a fallback sdesc = self.sdesc.get() - # add dbref is looker has control access and `noid` is not set - if self.access(looker, access_type="control") and not kwargs.get("noid", False): - sdesc = f"{sdesc}(#{self.id})" - return self.get_posed_sdesc(sdesc) if kwargs.get("pose", False) else sdesc def at_object_creation(self): diff --git a/evennia/contrib/rpg/rpsystem/tests.py b/evennia/contrib/rpg/rpsystem/tests.py index 202e244d13..155af26672 100644 --- a/evennia/contrib/rpg/rpsystem/tests.py +++ b/evennia/contrib/rpg/rpsystem/tests.py @@ -5,7 +5,6 @@ Tests for RP system import time from anything import Anything - from evennia import DefaultObject, create_object, default_cmds from evennia.commands.default.tests import BaseEvenniaCommandTest from evennia.utils.test_resources import BaseEvenniaTest @@ -414,14 +413,14 @@ class TestRPSystemCommands(BaseEvenniaCommandTest): expected_first_call = [ "More than one match for 'Mushroom' (please narrow target):", - f" Mushroom({mushroom1.dbref})-1 []", - f" Mushroom({mushroom2.dbref})-2 []", + f" Mushroom-1 []", + f" Mushroom-2 []", ] self.call(default_cmds.CmdLook(), "Mushroom", "\n".join(expected_first_call)) # PASSES - expected_second_call = f"Mushroom({mushroom1.dbref})\nThe first mushroom is brown." + expected_second_call = f"Mushroom(#{mushroom1.id})\nThe first mushroom is brown." self.call(default_cmds.CmdLook(), "Mushroom-1", expected_second_call) # FAILS - expected_third_call = f"Mushroom({mushroom2.dbref})\nThe second mushroom is red." + expected_third_call = f"Mushroom(#{mushroom2.id})\nThe second mushroom is red." self.call(default_cmds.CmdLook(), "Mushroom-2", expected_third_call) # FAILS diff --git a/evennia/objects/models.py b/evennia/objects/models.py index b58d48b177..a282bafe0f 100644 --- a/evennia/objects/models.py +++ b/evennia/objects/models.py @@ -19,7 +19,6 @@ from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.core.validators import validate_comma_separated_integer_list from django.db import models - from evennia.objects.manager import ObjectDBManager from evennia.typeclasses.models import TypedObject from evennia.utils import logger diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index b974f01ef4..ddbbc5269d 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -219,7 +219,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): # populated by `return_appearance` appearance_template = """ {header} -|c{name}|n +|c{name}{extra_name_info}|n {desc} {exits}{characters}{things} {footer} @@ -316,7 +316,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): "obj.location to move an object here.".format(self.__class__) ) - contents = property(contents_get, contents_set, contents_set) + contents = property(contents_get, contents_set, contents_set, contents_set) @property def exits(self): @@ -827,6 +827,16 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): for session in sessions: session.data_out(**kwargs) + def get_contents_unique(self, caller=None): + """ + Get a mapping of contents that are visually unique to the caller, along with + how many of each there are. + + Args: + caller (Object, optional): The object to check visibility from. If not given, + the current object will be used. + """ + def for_contents(self, func, exclude=None, **kwargs): """ Runs a function on every object contained within this one. @@ -1436,10 +1446,28 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): and is expected to produce something useful for builders. """ - if looker and self.locks.check_lockstring(looker, "perm(Builder)"): - return "{}(#{})".format(self.name, self.id) return self.name + def get_extra_display_name_info(self, looker=None, **kwargs): + """ + Adds any extra display information to the object's name. By default this is is the + object's dbref in parentheses, if the looker has permission to see it. + + Args: + looker (Object): The object looking at this object. + + Returns: + str: The dbref of this object, if the looker has permission to see it. Otherwise, an + empty string is returned. + + Notes: + By default, this becomes a string (#dbref) attached to the object's name. + + """ + if looker and self.locks.check_lockstring(looker, "perm(Builder)"): + return f"(#{self.id})" + return "" + def get_numbered_name(self, count, looker, **kwargs): """ Return the numbered (singular, plural) forms of this object's key. This is by default called @@ -1453,8 +1481,10 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): looker (Object): Onlooker. Not used by default. Keyword Args: - key (str): Optional key to pluralize. If not given, the object's `.name` property is - used. + key (str): Optional key to pluralize. If not given, the object's `.get_display_name()` + method is used. + return_string (bool): If `True`, return only the singular form if count is 0,1 or + the plural form otherwise. If `False` (default), return both forms as a tuple. Returns: tuple: This is a tuple `(str, str)` with the singular and plural forms of the key @@ -1466,7 +1496,8 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): """ plural_category = "plural_key" - key = kwargs.get("key", self.name) + key = kwargs.get("key", self.get_display_name(looker)) + raw_key = self.name key = ansi.ANSIString(key) # this is needed to allow inflection of colored names try: plural = _INFLECT.plural(key, count) @@ -1482,6 +1513,10 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): # save the singular form as an alias here too so we can display "an egg" and also # look at 'an egg'. self.aliases.add(singular, category=plural_category) + + if kwargs.get("return_string"): + return singular if count in (0, 1) else plural + return singular, plural def get_display_header(self, looker, **kwargs): @@ -1645,6 +1680,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): return self.format_appearance( self.appearance_template.format( name=self.get_display_name(looker, **kwargs), + extra_name_info=self.get_extra_display_name_info(looker, **kwargs), desc=self.get_display_desc(looker, **kwargs), header=self.get_display_header(looker, **kwargs), footer=self.get_display_footer(looker, **kwargs), diff --git a/evennia/utils/tests/test_utils.py b/evennia/utils/tests/test_utils.py index 9c5d4827f0..628037b418 100644 --- a/evennia/utils/tests/test_utils.py +++ b/evennia/utils/tests/test_utils.py @@ -11,12 +11,11 @@ from datetime import datetime, timedelta import mock from django.test import TestCase -from parameterized import parameterized -from twisted.internet import task - from evennia.utils import utils from evennia.utils.ansi import ANSIString from evennia.utils.test_resources import BaseEvenniaTest +from parameterized import parameterized +from twisted.internet import task class TestIsIter(TestCase): @@ -775,6 +774,54 @@ class TestJustify(TestCase): self.assertIn(ANSI_RED, str(result)) +class TestGroupObjectsByKeyAndDesc(TestCase): + """ + Test the utils.group_objects_by_key_and_desc function. + + """ + + class MockObject: + def __init__(self, key, desc): + self.key = key + self.desc = desc + + def get_display_name(self, looker, **kwargs): + return self.key + f" (looker: {looker.key})" + + def get_display_desc(self, looker, **kwargs): + return self.desc + f" (looker: {looker.key})" + + def get_numbered_name(self, count, looker, **kwargs): + return f"{count} {self.key} (looker: {looker.key})" + + def __repr__(self): + return f"MockObject({self.key}, {self.desc})" + + def test_group_by_key_and_desc(self): + ma1 = self.MockObject("itemA", "descA") + ma2 = self.MockObject("itemA", "descA") + ma3 = self.MockObject("itemA", "descA") + ma4 = self.MockObject("itemA", "descA") + + mb1 = self.MockObject("itemB", "descB") + mb2 = self.MockObject("itemB", "descB") + mb3 = self.MockObject("itemB", "descB") + + me = self.MockObject("Looker", "DescLooker") + + result = utils.group_objects_by_key_and_desc([ma1, ma2, ma3, ma4, mb1, mb2, mb3], caller=me) + + self.assertEqual( + list(result), + [ + ("4 itemA (looker: Looker)", "descA (looker: Looker)", [ma1, ma2, ma3, ma4]), + ("3 itemB (looker: Looker)", "descB (looker: Looker)", [mb1, mb2, mb3]), + ], + ) + + # Create a list of objects + + class TestMatchIP(TestCase): """ test utils.match_ip diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index afbc6f382f..b56ae3e7b2 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -28,6 +28,7 @@ from os.path import join as osjoin from string import punctuation from unicodedata import east_asian_width +import evennia from django.apps import apps from django.conf import settings from django.core.exceptions import ValidationError as DjangoValidationError @@ -35,14 +36,12 @@ from django.core.validators import validate_email as django_validate_email from django.utils import timezone from django.utils.html import strip_tags from django.utils.translation import gettext as _ +from evennia.utils import logger from simpleeval import simple_eval from twisted.internet import reactor, threads from twisted.internet.defer import returnValue # noqa - used as import target from twisted.internet.task import deferLater -import evennia -from evennia.utils import logger - _MULTIMATCH_TEMPLATE = settings.SEARCH_MULTIMATCH_TEMPLATE _EVENNIA_DIR = settings.EVENNIA_DIR _GAME_DIR = settings.GAME_DIR @@ -1767,6 +1766,41 @@ def string_partial_matching(alternatives, inp, ret_index=True): return [] +def group_objects_by_key_and_desc(objects, caller=None, **kwargs): + """ + Groups a list of objects by their key and description. This is used to group + visibly identical objects together, for example for inventory listings. + + Args: + objects (list): A list of objects to group. These must be DefaultObject. + + caller (Object, optional): The object looking at the objects, used to get the + description and key of each object. + **kwargs: Passed into each object's `get_display_name/desc` methods. + + Returns: + iterable: An iterable of tuples, where each tuple is on the form + `(numbered_name, description, [objects])`. + + """ + key_descs = defaultdict(list) + return_string = kwargs.pop("return_string", True) + + for obj in objects: + key_descs[ + (obj.get_display_name(caller, **kwargs), obj.get_display_desc(caller, **kwargs)) + ].append(obj) + + return ( + ( + objs[0].get_numbered_name(len(objs), caller, return_string=return_string, **kwargs), + desc, + objs, + ) + for (key, desc), objs in sorted(key_descs.items(), key=lambda tup: tup[0][0]) + ) + + def format_table(table, extra_space=1): """ Format a 2D array of strings into a multi-column table.