Added a wilderness area contrib

This commit is contained in:
Simon Vermeersch 2017-01-27 22:17:29 +01:00 committed by Griatch
parent a9f08760cc
commit bd27a7eabc
2 changed files with 841 additions and 0 deletions

View file

@ -298,4 +298,122 @@ class TestBarter(CommandTest):
self.call(barter.CmdFinish(), ": Ending.", "You say, \"Ending.\"\n [You aborted trade. No deal was made.]")
from evennia.contrib import wilderness
from evennia import DefaultCharacter
class TestWilderness(EvenniaTest):
def setUp(self):
super(TestWilderness, self).setUp()
self.char1 = create_object(DefaultCharacter, key="char1")
self.char2 = create_object(DefaultCharacter, key="char2")
def get_wilderness_script(self, name="default"):
w = wilderness.WildernessScript.objects.get("default")
return w
def test_create_wilderness_default_name(self):
wilderness.create_wilderness()
w = self.get_wilderness_script()
self.assertIsNotNone(w)
def test_create_wilderness_custom_name(self):
name = "customname"
wilderness.create_wilderness(name)
w = self.get_wilderness_script(name)
self.assertIsNotNone(w)
def test_enter_wilderness(self):
wilderness.create_wilderness()
wilderness.enter_wilderness(self.char1)
self.assertIsInstance(self.char1.location, wilderness.WildernessRoom)
w = self.get_wilderness_script()
self.assertEquals(w.db.itemlocations[self.char1], (0, 0))
def test_enter_wilderness_custom_location(self):
wilderness.create_wilderness()
wilderness.enter_wilderness(self.char1, location=(1, 2))
self.assertIsInstance(self.char1.location, wilderness.WildernessRoom)
w = self.get_wilderness_script()
self.assertEquals(w.db.itemlocations[self.char1], (1, 2))
def test_enter_wilderness_custom_name(self):
name = "customnname"
wilderness.create_wilderness(name)
wilderness.enter_wilderness(self.char1, name=name)
self.assertIsInstance(self.char1.location, wilderness.WildernessRoom)
def test_wilderness_correct_exits(self):
wilderness.create_wilderness()
wilderness.enter_wilderness(self.char1)
# By default we enter at a corner (0, 0), so only a few exits should
# be visible / traversable
exits = [i for i in self.char1.location.contents
if i.destination and (
i.access(self.char1, "view") or
i.access(self.char1, "traverse"))]
self.assertEquals(len(exits), 3)
exitsok = ["north", "northeast", "east"]
for exit in exitsok:
self.assertTrue(any([e for e in exits if e.key == exit]))
# If we move to another location not on an edge, then all directions
# should be visible / traversable
wilderness.enter_wilderness(self.char1, location=(1, 1))
exits = [i for i in self.char1.location.contents
if i.destination and (
i.access(self.char1, "view") or
i.access(self.char1, "traverse"))]
self.assertEquals(len(exits), 8)
exitsok = ["north", "northeast", "east", "southeast", "south",
"southwest", "west", "northwest"]
for exit in exitsok:
self.assertTrue(any([e for e in exits if e.key == exit]))
def test_room_creation(self):
# Pretend that both char1 and char2 are connected...
self.char1.sessions.add(1)
self.char2.sessions.add(1)
self.assertTrue(self.char1.has_player)
self.assertTrue(self.char2.has_player)
wilderness.create_wilderness()
w = self.get_wilderness_script()
# We should have no unused room after moving the first player in.
self.assertEquals(len(w.db.unused_rooms), 0)
w.move_obj(self.char1, (0, 0))
self.assertEquals(len(w.db.unused_rooms), 0)
# And also no unused room after moving the second one in.
w.move_obj(self.char2, (1, 1))
self.assertEquals(len(w.db.unused_rooms), 0)
# But if char2 moves into char1's room, we should have one unused room
# Which should be char2's old room that got created.
w.move_obj(self.char2, (0, 0))
self.assertEquals(len(w.db.unused_rooms), 1)
self.assertEquals(self.char1.location, self.char2.location)
# And if char2 moves back out, that unused room should be put back to
# use again.
w.move_obj(self.char2, (1, 1))
self.assertNotEquals(self.char1.location, self.char2.location)
self.assertEquals(len(w.db.unused_rooms), 0)
def test_get_new_location(self):
loc = (1, 1)
directions = {"north": (1, 2),
"northeast": (2, 2),
"east": (2, 1),
"southeast": (2, 0),
"south": (1, 0),
"southwest": (0, 0),
"west": (0, 1),
"northwest": (0, 2)}
for direction, correct_loc in directions.iteritems():
new_loc = wilderness.get_new_location(loc, direction)
self.assertEquals(new_loc, correct_loc, direction)

View file

@ -0,0 +1,723 @@
"""
Wilderness system
Evennia contrib - titeuf87 2017
This contrib provides a wilderness map. This is an area that can be huge where
the rooms are mostly similar, except for some small cosmetic changes like the
room name.
Usage:
This contrib does not provide any commands. Instead the @py command can be
used.
A wilderness map needs to created first. There can be different maps, all
with their own name. If no name is provided, then a default one is used.
@py from evennia.contrib import wilderness; wilderness.create_wilderness()
Once created, it is possible to move into that wilderness map:
@py from evennia.contrib import wilderness; wilderness.enter_wilderness(me)
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 x = 0
is on the left and y = 0 is at the bottom of the map.
Customisation:
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
is unlimited in size.
This 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
needs.
Customisation example:
To give an example of how to customize, we will create a very simple (and
small) wilderness map that is shaped like a pyramid. The map will be
provided as a string: a "." symbol is a location we can walk on.
Let's create a file world/pyramid.py:
```python
map_str = \"\"\"
.
...
.....
.......
\"\"\"
from evennia.contrib import wilderness
class PyramidMapProvider(wilderness.WildernessMapProvider):
def is_valid_coordinates(self, wilderness, coordinates):
x, y = coordinates
try:
lines = map_str.split("\n")
# The reverse is needed because otherwise the pyramid will be
# upside down
lines.reverse()
line = lines[y]
column = line[x]
return column == "."
except IndexError:
return False
def get_location_name(self, coordinates):
x, y = coordinates
if y == 3:
return "Atop the pyramid."
else:
return "Inside a pyramid."
```
Now we can use our new pyramid-shaped wilderness map. From inside Evennia:
```
@py from world import pyramid as p; p.wilderness.create_wilderness(mapprovider=p.PyramidMapProvider())
@py from evennia.contrib import wilderness; wilderness.enter_wilderness(me, coordinates=(4, 1))
```
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.
"""
from evennia import DefaultRoom, DefaultExit, DefaultScript
from evennia import create_object, create_script
from evennia.utils import inherits_from
def create_wilderness(name="default", mapprovider=None):
"""
Creates a new wilderness map. Does nothing if a wilderness map already
exists with the same name.
Args:
name (str, optional): the name to use for that wilderness map
mapprovider (WildernessMap instance, optional): an instance of a
WildernessMap class (or subclass) that will be used to provide the
layout of this wilderness map. If none is provided, the default
infinite grid map will be used.
"""
if WildernessScript.objects.filter(db_key=name).exists():
# Don't create two wildernesses with the same name
return
if not mapprovider:
mapprovider = WildernessMapProvider()
script = create_script(WildernessScript, key=name)
script.db.mapprovider = mapprovider
def enter_wilderness(obj, coordinates=(0, 0), name="default"):
"""
Moves obj into the wilderness. The wilderness needs to exist first and the
provided coordinates needs to be valid inside that wilderness.
Args:
obj (object): the object to move into the wilderness
coordinates (tuple), optional): the coordinates to move obj to into
the wilderness. If not provided, defaults (0, 0)
name (str, optional): name of the wilderness map, if not using the
default one
Returns:
bool: True if obj succesfully moved into the wilderness.
"""
if not WildernessScript.objects.filter(db_key=name).exists():
return False
script = WildernessScript.objects.get(db_key=name)
if script.is_valid_coordinates(coordinates):
script.move_obj(obj, coordinates)
return True
else:
return False
def get_new_coordinates(coordinates, direction):
"""
Returns the coordinates of direction applied to the provided coordinates.
Args:
coordinates: tuple of (x, y)
direction: a direction string (like "northeast")
Returns:
tuple: tuple of (x, y) coordinates
"""
x, y = coordinates
if direction in ("north", "northwest", "northeast"):
y += 1
if direction in ("south", "southwest", "southeast"):
y -= 1
if direction in ("northwest", "west", "southwest"):
x -= 1
if direction in ("northeast", "east", "southeast"):
x += 1
return (x, y)
class WildernessRoom(DefaultRoom):
"""
This is a single room inside the wilderness. This room provides a "view"
into the wilderness map. When a player moves around, instead of going to
another room as with traditional rooms, they stay in the same room but the
room itself changes to display another area of the wilderness.
"""
@property
def wilderness(self):
"""
Shortcut property to the wilderness script this room belongs to.
Returns:
WildernessScript: the WildernessScript attached to this room
"""
return self.ndb.wildernessscript
@property
def location_name(self):
"""
Returns the name of the wilderness at this room's coordinates.
Returns:
name (str)
"""
return self.wilderness.mapprovider.get_location_name(
self.coordinates)
@property
def coordinates(self):
"""
Returns the coordinates of this room into the wilderness.
Returns:
tuple: (x, y) coordinates of where this room is inside the
wilderness.
"""
return self.ndb.active_coordinates
def at_object_receive(self, moved_obj, source_location):
"""
Called after an object has been moved into this object. This is a
default Evennia hook.
Args:
moved_obj (Object): The object moved into this one.
source_location (Object): Where `moved_obj` came from.
"""
if moved_obj.destination and moved_obj.destination == moved_obj.location:
# Ignore exits looping back to themselves: those are the regular
# n, ne, ... exits.
return
itemcoords = self.wilderness.db.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
moved_obj.location = None
self.wilderness.move_obj(moved_obj, coordinates)
else:
# This object wasn't in the wilderness yet. Let's add it.
itemcoords[moved_obj] = self.coordinates
def at_object_leave(self, moved_obj, target_location):
"""
Called just before an object leaves from inside this object. This is a
default Evennia hook.
Args:
moved_obj (Object): The object leaving
target_location (Object): Where `moved_obj` is going.
"""
self.wilderness.at_after_object_leave(moved_obj)
def set_active_coordinates(self, new_coordinates):
"""
Changes this room to show the wilderness map from other coordinates.
Args:
new_coordinates (tuple): coordinates as tuple of (x, y)
"""
# Remove the reference for the old coordinates...
rooms = self.wilderness.db.rooms
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
for item in self.contents:
if not item.destination or item.destination != item.location:
item.location = None
# And every obj matching the new coordinates will get its location set
# to this room
for item in self.wilderness.get_objs_at_coordinates(new_coordinates):
item.location = self
# Fix the lockfuncs for the exit so we can't go where we're not
# supposed to go
for exit in self.exits:
if exit.destination != self:
continue
x, y = get_new_coordinates(new_coordinates, exit.key)
valid = self.wilderness.is_valid_coordinates((x, y))
if valid:
exit.locks.add("traverse:true();view:true()")
else:
exit.locks.add("traverse:false();view:false()")
def get_display_name(self, looker, **kwargs):
"""
Displays the name of the object in a viewer-aware manner.
Args:
looker (TypedObject): The object or player that is looking
at/getting inforamtion for this object.
Returns:
name (str): A string containing the name of the object,
including the DBREF if this user is privileged to control
said object and also its coordinates into the wilderness map.
Notes:
This function could be extended to change how object names
appear to users in character, but be wary. This function
does not change an object's keys or aliases when
searching, and is expected to produce something useful for
builders.
"""
if self.locks.check_lockstring(looker, "perm(Builders)"):
name = "{}(#{})".format(self.location_name, self.id)
else:
name = self.location_name
name += " {0}".format(self.coordinates)
return name
class WildernessExit(DefaultExit):
"""
This is an Exit object used inside a WildernessRoom. Instead of changing
the location of an Object traversing through it (like a traditional exit
would do) it changes the coordinates of that traversing Object inside
the wilderness map.
"""
@property
def wilderness(self):
"""
Shortcut property to the wilderness script.
Returns:
WildernessScript: the WildernessScript attached to this exit's room
"""
return self.location.wilderness
@property
def mapprovider(self):
"""
Shortcut property to the map provider.
Returns:
MapProvider object: the mapprovider object used with this
wilderness map.
"""
return self.wilderness.mapprovider
def at_traverse_coordinates(self, traversing_object, current_coordinates,
new_coordinates):
"""
Called when an object wants to travel from one place inside the
wilderness to another place inside the wilderness.
If this returns True, then the traversing can happen. Otherwise it will
be blocked.
This method is similar how the `at_traverse` works on normal exits.
Args:
traversing_object (Object): The object doing the travelling.
current_coordinates (tuple): (x, y) coordinates where
`traversing_object` currently is.
new_coordinates (tuple): (x, y) coordinates of where
`traversing_object` wants to travel to.
Returns:
bool: True if traversing_object is allowed to traverse
"""
return True
def at_traverse(self, traversing_object, target_location):
"""
This implements the actual traversal. The traverse lock has
already been checked (in the Exit command) at this point.
Args:
traversing_object (Object): Object traversing us.
target_location (Object): Where target is going.
Returns:
bool: True if the traverse is allowed to happen
"""
itemcoordinates = self.location.wilderness.db.itemcoordinates
current_coordinates = itemcoordinates[traversing_object]
new_coordinates = get_new_coordinates(current_coordinates, self.key)
if not self.at_traverse_coordinates(traversing_object,
current_coordinates,
new_coordinates):
return False
if not traversing_object.at_before_move(None):
return False
traversing_object.location.msg_contents("{} leaves to {}".format(
traversing_object.key, new_coordinates),
exclude=[traversing_object])
self.location.wilderness.move_obj(traversing_object, new_coordinates)
traversing_object.location.msg_contents("{} arrives from {}".format(
traversing_object.key, current_coordinates),
exclude=[traversing_object])
traversing_object.at_after_move(None)
return True
class WildernessMapProvider(object):
"""
Default Wilderness Map provider.
This is a simple provider that just creates an infite large grid area.
"""
room_typeclass = WildernessRoom
exit_typeclass = WildernessExit
def is_valid_coordinates(self, wilderness, coordinates):
"""Returns True if coordinates is valid and can be walked to.
Args:
wilderness: the wilderness script
coordinates (tuple): the coordinates to check as (x, y) tuple.
Returns:
bool: True if the coordinates are valid
"""
x, y = coordinates
if x < 0:
return False
if y < 0:
return False
return True
def get_location_name(self, coordinates):
"""
Returns a name for the position at coordinates.
Args:
coordinates (tuple): the coordinates as (x, y) tuple.
Returns:
name (str)
"""
return "The wilderness"
class WildernessScript(DefaultScript):
"""
This is the main "handler" for the wilderness system: inside here the
coordinates of every item currently inside the wilderness is stored. This
script is responsible for creating rooms as needed and storing rooms away
into storage when they are not needed anymore.
"""
def at_script_creation(self):
"""
Only called once, when the script is created. This is a default Evennia
hook.
"""
self.persistent = True
# Store the coordinates of every item that is inside the wilderness
# Key: object, Value: (x, y)
self.db.itemcoordinates = {}
# Store the rooms that are used as views into the wilderness
# Key: (x, y), Value: room object
self.db.rooms = {}
# Created rooms that are not needed anymore are stored there. This
# allows quick retrieval if a new room is needed without having to
# create it.
self.db.unused_rooms = []
@property
def mapprovider(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.
"""
for coordinates, room in self.db.rooms.items():
room.ndb.wildernessscript = self
room.ndb.active_coordinates = coordinates
for item in self.db.itemcoordinates.keys():
item.ndb.wilderness = self
def is_valid_coordinates(self, coordinates):
"""
Returns True if coordinates are valid (and can be travelled to).
Otherwise returns False
Args:
coordinates (tuple): coordinates as (x, y) tuple
Returns:
bool: True if the coordinates are valid
"""
return self.mapprovider.is_valid_coordinates(self, coordinates)
def get_obj_coordinates(self, obj):
"""
Returns the coordinates of obj in the wilderness.
Returns (x, y)
Args:
obj (object): an object inside the wilderness
Returns:
tuple: (x, y) tuple of where obj is located
"""
return self.itemcoordinates[obj]
def get_objs_at_coordinates(self, coordinates):
"""
Returns a list of every object at certain coordinates.
Imeplementation detail: this uses a naive iteration through every
object inside the wilderness which could cause slow downs when there
are a lot of objects in the map.
Args:
coordinates (tuple): a coordinate tuple like (x, y)
Returns:
[Object, ]: list of Objects at coordinates
"""
result = []
for item, item_coordinates in self.itemcoordinates.items():
if coordinates == item_coordinates:
result.append(item)
return result
def move_obj(self, obj, new_coordinates):
"""
Moves obj to new coordinates in this wilderness.
Args:
obj (object): the object to move
new_coordinates (tuple): tuple of (x, y) where to move obj to.
"""
# Update the position of this obj in the wilderness
self.itemcoordinates[obj] = new_coordinates
old_room = obj.location
# Remove the obj's location. This is needed so that the object does not
# 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]
# There is. Try to destroy the old_room if it is not needed anymore
self._destroy_room(old_room)
except KeyError:
# 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
if old_room.wilderness != self:
# ... but that other wilderness room belongs to another
# wilderness map
create_new_room = True
old_room.wilderness.at_after_object_leave(obj)
else:
for item in old_room.contents:
if item.has_player:
# 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
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.location = room
obj.ndb.wilderness = self
def _create_room(self, coordinates, report_to):
"""
Gets a new WildernessRoom to be used for the provided coordinates.
It first tries to retrieve a room out of storage. If there are no rooms
left a new one will be created.
Args:
coordinates (tuple): coordinate tuple of (x, y)
report_to (object): the obj to return error messages to
"""
if self.db.unused_rooms:
# There is still unused rooms stored in storage, let's get one of
# those
room = self.db.unused_rooms.pop()
else:
# No more unused rooms...time to make a new one.
# First, create the room
room = create_object(typeclass=self.mapprovider.room_typeclass,
key="Wilderness",
report_to=report_to)
# Then the exits
exits = [("north", "n"),
("northeast", "ne"),
("east", "e"),
("southeast", "se"),
("south", "s"),
("southwest", "sw"),
("west", "w"),
("northwest", "nw")]
for key, alias in exits:
create_object(typeclass=self.mapprovider.exit_typeclass,
key=key,
aliases=[alias],
location=room,
destination=room,
report_to=report_to)
room.ndb.active_coordinates = coordinates
room.ndb.wildernessscript = self
self.db.rooms[coordinates] = room
return room
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.
Args:
room (WildernessRoom): the room to put in storage
"""
if not room or not inherits_from(room, WildernessRoom):
return
for item in room.contents:
if item.has_player:
# 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.
# 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
# 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)
def at_after_object_leave(self, obj):
"""
Called after an object left this wilderness map. Used for cleaning up.
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)