Merge pull request #2990 from InspectorCaracal/wilderness-update

Update wilderness contrib for 1.0
This commit is contained in:
Griatch 2022-11-25 11:53:06 +01:00 committed by GitHub
commit ce6c085fbc
3 changed files with 180 additions and 126 deletions

View file

@ -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.

View file

@ -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)])

View file

@ -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):
"""