diff --git a/evennia/contrib/grid/wilderness/README.md b/evennia/contrib/grid/wilderness/README.md index 1e83975e4b..261060eceb 100644 --- a/evennia/contrib/grid/wilderness/README.md +++ b/evennia/contrib/grid/wilderness/README.md @@ -5,7 +5,7 @@ Contribution by titeuf87, 2017 This contrib provides a wilderness map without actually creating a large number of rooms - as you move, you instead end up back in the same room but its description changes. This means you can make huge areas with little database use as -long as the rooms are relatively similar (name/desc changing). +long as the rooms are relatively similar (e.g. only the names/descs changing). ## Installation @@ -29,6 +29,9 @@ All coordinates used by the wilderness map are in the format of `(x, y)` tuples. x goes from left to right and y goes from bottom to top. So `(0, 0)` is the bottom left corner of the map. +> You can also add a wilderness by defining a WildernessScript in your GLOBAL_SCRIPT +> settings. If you do, make sure define the map provider. + ## Customisation The defaults, while useable, are meant to be customised. When creating a @@ -37,9 +40,14 @@ python object that is smart enough to create the map. The default provider, `WildernessMapProvider`, just creates a grid area that is unlimited in size. -This `WildernessMapProvider` can be subclassed to create more interesting + +`WildernessMapProvider` can be subclassed to create more interesting maps and also to customize the room/exit typeclass used. +The `WildernessScript` also has an optional `preserve_items` property, which +when set to `True` will not recycle rooms that contain any objects. By default, +a wilderness room is recycled whenever there are no players left in it. + There is also no command that allows players to enter the wilderness. This still needs to be added: it can be a command or an exit, depending on your needs. @@ -94,7 +102,7 @@ class PyramidMapProvider(wilderness.WildernessMapProvider): desc = "This is a room in the pyramid." if y == 3 : desc = "You can see far and wide from the top of the pyramid." - room.db.desc = desc + room.ndb.desc = desc ``` Now we can use our new pyramid-shaped wilderness map. From inside Evennia we @@ -105,9 +113,13 @@ create a new wilderness (with the name "default") but using our new map provider ## Implementation details -When a character moves into the wilderness, they get their own room. If they -move, instead of moving the character, the room changes to match the new -coordinates. If a character meets another character in the wilderness, then -their room merges. When one of the character leaves again, they each get their -own separate rooms. Rooms are created as needed. Unneeded rooms are stored away -to avoid the overhead cost of creating new rooms again in the future. +When a character moves into the wilderness, they get their own room. If +they move, instead of moving the character, the room changes to match the +new coordinates. + +If a character meets another character in the wilderness, then their room +merges. When one of the character leaves again, they each get their own +separate rooms. + +Rooms are created as needed. Unneeded rooms are stored away to avoid the +overhead cost of creating new rooms again in the future. diff --git a/evennia/contrib/grid/wilderness/tests.py b/evennia/contrib/grid/wilderness/tests.py index fee2fd17e4..ec7304e100 100644 --- a/evennia/contrib/grid/wilderness/tests.py +++ b/evennia/contrib/grid/wilderness/tests.py @@ -36,14 +36,14 @@ class TestWilderness(BaseEvenniaTest): wilderness.enter_wilderness(self.char1) self.assertIsInstance(self.char1.location, wilderness.WildernessRoom) w = self.get_wilderness_script() - self.assertEqual(w.db.itemcoordinates[self.char1], (0, 0)) + self.assertEqual(w.itemcoordinates[self.char1], (0, 0)) def test_enter_wilderness_custom_coordinates(self): wilderness.create_wilderness() wilderness.enter_wilderness(self.char1, coordinates=(1, 2)) self.assertIsInstance(self.char1.location, wilderness.WildernessRoom) w = self.get_wilderness_script() - self.assertEqual(w.db.itemcoordinates[self.char1], (1, 2)) + self.assertEqual(w.itemcoordinates[self.char1], (1, 2)) def test_enter_wilderness_custom_name(self): name = "customnname" @@ -133,6 +133,33 @@ class TestWilderness(BaseEvenniaTest): "west": (0, 1), "northwest": (0, 2), } - for (direction, correct_loc) in directions.items(): # Not compatible with Python 3 + for direction, correct_loc in directions.items(): new_loc = wilderness.get_new_coordinates(loc, direction) self.assertEqual(new_loc, correct_loc, direction) + + def test_preserve_items(self): + wilderness.create_wilderness() + w = self.get_wilderness_script() + + # move char and obj to wilderness + wilderness.enter_wilderness(self.char1) + wilderness.enter_wilderness(self.obj1) + + # move to a new room + w.move_obj(self.char1, (1, 1)) + # the room should be remapped and 0,0 should not exist + self.assertTrue((0, 0) not in w.db.rooms) + self.assertEqual(1, len(w.db.rooms)) + # verify obj1 moved to None + self.assertIsNone(self.obj1.location) + + # now change to preserve items + w.preserve_items = True + wilderness.enter_wilderness(self.obj1, (1, 1)) + # move the character again + w.move_obj(self.char1, (0, 1)) + # check that the previous room was preserved + self.assertIn((1, 1), w.db.rooms) + self.assertEqual(2, len(w.db.rooms)) + # and verify that obj1 is still at 1,1 + self.assertEqual(self.obj1.location, w.db.rooms[(1, 1)]) diff --git a/evennia/contrib/grid/wilderness/wilderness.py b/evennia/contrib/grid/wilderness/wilderness.py index e2270057d1..07274bf120 100644 --- a/evennia/contrib/grid/wilderness/wilderness.py +++ b/evennia/contrib/grid/wilderness/wilderness.py @@ -34,10 +34,11 @@ The defaults, while useable, are meant to be customised. When creating a new wilderness map it is possible to give a "map provider": this is a python object that is smart enough to create the map. -The default provider, `WildernessMapProvider`, just creates a grid area that +The default provider, `WildernessMapProvider`, creates a grid area that is unlimited in size. -This `WildernessMapProvider` can be subclassed to create more interesting -maps and also to customize the room/exit typeclass used. + +`WildernessMapProvider` can be subclassed to create more interesting maps +and also to customize the room/exit typeclass used. There is also no command that allows players to enter the wilderness. This still needs to be added: it can be a command or an exit, depending on your @@ -91,7 +92,7 @@ class PyramidMapProvider(wilderness.WildernessMapProvider): desc = "This is a room in the pyramid." if y == 3 : desc = "You can see far and wide from the top of the pyramid." - room.db.desc = desc + room.ndb.desc = desc ``` Now we can use our new pyramid-shaped wilderness map. From inside Evennia we @@ -105,9 +106,11 @@ create a new wilderness (with the name "default") but using our new map provider When a character moves into the wilderness, they get their own room. If they move, instead of moving the character, the room changes to match the new coordinates. + If a character meets another character in the wilderness, then their room merges. When one of the character leaves again, they each get their own separate rooms. + Rooms are created as needed. Unneeded rooms are stored away to avoid the overhead cost of creating new rooms again in the future. @@ -121,9 +124,9 @@ from evennia import ( create_script, ) from evennia.utils import inherits_from +from evennia.typeclasses.attributes import AttributeProperty - -def create_wilderness(name="default", mapprovider=None): +def create_wilderness(name="default", mapprovider=None, preserve_items=False): """ Creates a new wilderness map. Does nothing if a wilderness map already exists with the same name. @@ -144,6 +147,8 @@ def create_wilderness(name="default", mapprovider=None): mapprovider = WildernessMapProvider() script = create_script(WildernessScript, key=name) script.db.mapprovider = mapprovider + if preserve_items: + script.preserve_items = True def enter_wilderness(obj, coordinates=(0, 0), name="default"): @@ -161,10 +166,12 @@ def enter_wilderness(obj, coordinates=(0, 0), name="default"): Returns: bool: True if obj successfully moved into the wilderness. """ - if not WildernessScript.objects.filter(db_key=name).exists(): + script = WildernessScript.objects.filter(db_key=name) + if not script.exists(): return False + else: + script = script[0] - script = WildernessScript.objects.get(db_key=name) if script.is_valid_coordinates(coordinates): script.move_obj(obj, coordinates) return True @@ -205,6 +212,18 @@ class WildernessScript(DefaultScript): into storage when they are not needed anymore. """ + # Stores the MapProvider class + mapprovider = AttributeProperty() + + # Stores a dictionary of items on the map with their coordinates + # The key is the item, the value are the coordinates as (x, y) tuple. + itemcoordinates = AttributeProperty() + + # Determines whether or not rooms are recycled despite containing non-player objects + # True means that leaving behind a non-player object will prevent the room from being recycled + # in order to preserve the object + preserve_items = AttributeProperty(default=False) + def at_script_creation(self): """ Only called once, when the script is created. This is a default Evennia @@ -224,39 +243,17 @@ class WildernessScript(DefaultScript): # allows quick retrieval if a new room is needed without having to # create it. self.db.unused_rooms = [] - - @property - def mapprovider(self): + + def at_server_start(self): """ - Shortcut property to the map provider. - - Returns: - MapProvider: the mapprovider used with this wilderness - """ - return self.db.mapprovider - - @property - def itemcoordinates(self): - """ - Returns a dictionary with the coordinates of every item inside this - wilderness map. The key is the item, the value are the coordinates as - (x, y) tuple. - - Returns: - {item: coordinates} - """ - return self.db.itemcoordinates - - def at_start(self): - """ - Called when the script is started and also after server reloads. + Called after the server is started or reloaded. """ for coordinates, room in self.db.rooms.items(): room.ndb.wildernessscript = self room.ndb.active_coordinates = coordinates - for item in list(self.db.itemcoordinates.keys()): - # Items deleted from the wilderness leave None type 'ghosts' - # that must be cleaned out + for item in self.db.itemcoordinates.keys(): + # Items deleted while in the wilderness can leave None-type 'ghosts' + # These need to be cleaned up if item is None: del self.db.itemcoordinates[item] continue @@ -303,16 +300,8 @@ class WildernessScript(DefaultScript): Returns: [Object, ]: list of Objects at coordinates """ - result = [] - for item, item_coordinates in list(self.itemcoordinates.items()): - # Items deleted from the wilderness leave None type 'ghosts' - # that must be cleaned out - if item is None: - del self.db.itemcoordinates[item] - continue - if coordinates == item_coordinates: - result.append(item) - return result + result = [ item for item, item_coords in self.itemcoordinates.items() if item_coords == coordinates and item is not None ] + return list(result) def move_obj(self, obj, new_coordinates): """ @@ -330,45 +319,53 @@ class WildernessScript(DefaultScript): # appear in its old room should that room be deleted. obj.location = None - try: - # See if we already have a room for that location - room = self.db.rooms[new_coordinates] + # By default, we'll assume we won't be making a new room and change this flag if necessary. + create_room = False + + # See if we already have a room for that location + if room := self.db.rooms.get(new_coordinates): # There is. Try to destroy the old_room if it is not needed anymore self._destroy_room(old_room) - except KeyError: + else: # There is no room yet at new_location - if (old_room and not inherits_from(old_room, WildernessRoom)) or (not old_room): - # Obj doesn't originally come from a wilderness room. - # We'll create a new one then. - room = self._create_room(new_coordinates, obj) - else: - # Obj does come from another wilderness room - create_new_room = False + # Is the old room in a wilderness? + if hasattr(old_room, "wilderness"): + # Yes. Is it in THIS wilderness? + if old_room.wilderness == self: + # Should we preserve rooms with any objects? + if self.preserve_items: + # Yes - check if ANY objects besides the exits are in old_room + if len([ob for ob in old_room.contents if not inherits_from(ob, WildernessExit)]): + # There is, so we'll create a new room + room = self._create_room(new_coordinates, obj) + else: + # The room is empty, so we'll reuse it + room = old_room + else: + # Only preserve rooms if there are players behind + if len([ob for ob in old_room.contents if ob.has_account]): + # There is still a player there; create a new room + room = self._create_room(new_coordinates, obj) + else: + # The room is empty of players, so we'll reuse it + room = old_room - if old_room.wilderness != self: - # ... but that other wilderness room belongs to another - # wilderness map - create_new_room = True + # It's in a different wilderness + else: + # It does, so we make sure to leave the other wilderness properly old_room.wilderness.at_post_object_leave(obj) - else: - for item in old_room.contents: - if item.has_account: - # There is still a player in the old room. - # Let's create a new room and not touch that old - # room. - create_new_room = True - break - - if create_new_room: - # Create a new room to hold obj, not touching any obj's in - # the old room + # We'll also need to create a new room in this wilderness room = self._create_room(new_coordinates, obj) - else: - # The old_room is empty: we are just going to reuse that - # room instead of creating a new one - room = old_room - room.set_active_coordinates(new_coordinates, obj) + else: + # Obj comes from outside the wilderness entirely + # We need to make a new room + room = self._create_room(new_coordinates, obj) + + # Set `room` to the new coordinates, however it was made + room.set_active_coordinates(new_coordinates, obj) + + # Put obj back, now in the correct room obj.location = room obj.ndb.wilderness = self @@ -425,7 +422,11 @@ class WildernessScript(DefaultScript): def _destroy_room(self, room): """ Moves a room back to storage. If room is not a WildernessRoom or there - is a player inside the room, then this does nothing. + is something left inside the room, then this does nothing. + + Implementation note: If `preserve_items` is False (the default) then any + objects left in the rooms will be moved to None. You may want to implement + your own cleanup or recycling routine for these objects. Args: room (WildernessRoom): the room to put in storage @@ -433,25 +434,30 @@ class WildernessScript(DefaultScript): if not room or not inherits_from(room, WildernessRoom): return + # Check the contents of the room before recycling for item in room.contents: if item.has_account: - # There is still a character in that room. We can't get rid of - # it just yet - break - else: - # No characters left in the room. + # There is still a player in this room, we can't delete it yet. + return - # Clear the location of every obj in that room first - for item in room.contents: - if item.destination and item.destination == room: - # Ignore the exits, they stay in the room - continue - item.location = None + if not (item.destination and item.destination == room): + # There is still a non-exit object in the room. Should we preserve it? + if self.preserve_items: + # Yes, so we can't get rid of the room just yet + return - # Then delete its reference - del self.db.rooms[room.ndb.active_coordinates] - # And finally put this room away in storage - self.db.unused_rooms.append(room) + # If we get here, the room can be recycled + # Clear the location of any objects left in that room first + for item in room.contents: + if item.destination and item.destination == room: + # Ignore the exits, they stay in the room + continue + item.location = None + + # Then delete its coordinate reference + del self.db.rooms[room.ndb.active_coordinates] + # And finally put this room away in storage + self.db.unused_rooms.append(room) def at_post_object_leave(self, obj): """ @@ -460,13 +466,13 @@ class WildernessScript(DefaultScript): Args: obj (object): the object that left """ - # Remove that obj from the wilderness's coordinates dict - loc = self.db.itemcoordinates[obj] - del self.db.itemcoordinates[obj] - - # And see if we can put that room away into storage. - room = self.db.rooms[loc] - self._destroy_room(room) + # Try removing the object from the coordinates system + if loc := self.db.itemcoordinates.pop(obj, None): + # The object was removed successfully + # Make sure there was a room at that location + if room := self.db.rooms.get(loc): + # If so, try to clean up the room + self._destroy_room(room) class WildernessRoom(DefaultRoom): @@ -522,21 +528,13 @@ class WildernessRoom(DefaultRoom): # n, ne, ... exits. return - itemcoords = self.wilderness.db.itemcoordinates + itemcoords = self.wilderness.itemcoordinates if moved_obj in itemcoords: # This object was already in the wilderness. We need to make sure # it goes to the correct room it belongs to. - # Otherwise the following issue can come up: - # 1) Player 1 and Player 2 share a room - # 2) Player 1 disconnects - # 3) Player 2 moves around - # 4) Player 1 reconnects - # Player 1 will end up in player 2's room, which has the wrong - # coordinates - coordinates = itemcoords[moved_obj] # Setting the location to None is important here so that we always - # get a "fresh" room + # get a "fresh" room if it was in the wrong place moved_obj.location = None self.wilderness.move_obj(moved_obj, coordinates) else: @@ -564,14 +562,16 @@ class WildernessRoom(DefaultRoom): obj (Object): the object that moved into this room and caused the coordinates to change """ - # Remove the reference for the old coordinates... + # Remove any reference for the old coordinates... rooms = self.wilderness.db.rooms - del rooms[self.coordinates] + if self.coordinates: + del rooms[self.coordinates] # ...and add it for the new coordinates. self.ndb.active_coordinates = new_coordinates rooms[self.coordinates] = self - # Every obj inside this room will get its location set to None + # Any object inside this room will get its location set to None + # unless it's a wilderness exit for item in self.contents: if not item.destination or item.destination != item.location: item.location = None @@ -600,6 +600,7 @@ class WildernessRoom(DefaultRoom): def get_display_name(self, looker, **kwargs): """ Displays the name of the object in a viewer-aware manner. + This is a core evennia hook. Args: looker (TypedObject): The object or account that is looking @@ -624,7 +625,21 @@ class WildernessRoom(DefaultRoom): name += " {0}".format(self.coordinates) return name + + def get_display_desc(self, looker, **kwargs): + """ + Displays the description of the room. This is a core evennia hook. + + Allows the room's description to be customized in an ndb value, + avoiding having to write to the database on moving. + """ + # Check if a new description was prepared by the map provider + if self.ndb.active_desc: + # There is one: use it + return self.ndb.active_desc + # Otherwise, use the normal description hook. + return super().get_display_desc(looker, **kwargs) class WildernessExit(DefaultExit): """