From a471f0fd86cdbcf9aec70449624ebf07175b62b2 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 24 Jul 2022 20:26:41 +0200 Subject: [PATCH] Fix dungeon behavior --- .../tutorials/evadventure/build_techdemo.py | 75 +++++- .../contrib/tutorials/evadventure/dungeon.py | 246 +++++++++++++++--- .../contrib/tutorials/evadventure/rooms.py | 31 +-- .../evadventure/tests/test_dungeon.py | 6 +- evennia/locks/lockfuncs.py | 30 +++ 5 files changed, 314 insertions(+), 74 deletions(-) diff --git a/evennia/contrib/tutorials/evadventure/build_techdemo.py b/evennia/contrib/tutorials/evadventure/build_techdemo.py index e6a763e9bf..b7e2ad2bf8 100644 --- a/evennia/contrib/tutorials/evadventure/build_techdemo.py +++ b/evennia/contrib/tutorials/evadventure/build_techdemo.py @@ -20,16 +20,12 @@ You can also build/rebuild individiaul #CODE blocks in the `batchcode/interactiv # this is loaded at the top of every #CODE block from evennia import DefaultExit, create_object, search_object -from evennia.contrib.tutorials import evadventure from evennia.contrib.tutorials.evadventure import npcs -from evennia.contrib.tutorials.evadventure.combat_turnbased import EvAdventureCombatHandler -from evennia.contrib.tutorials.evadventure.objects import ( - EvAdventureConsumable, - EvAdventureObject, - EvAdventureObjectFiller, - EvAdventureRunestone, - EvAdventureWeapon, +from evennia.contrib.tutorials.evadventure.dungeon import ( + EvAdventureDungeonStartRoom, + EvAdventureDungeonStartRoomExit, ) +from evennia.contrib.tutorials.evadventure.objects import EvAdventureWeapon from evennia.contrib.tutorials.evadventure.rooms import EvAdventurePvPRoom, EvAdventureRoom # CODE @@ -66,7 +62,7 @@ create_object( # with a static enemy combat_room = create_object(EvAdventurePvPRoom, key="Combat Arena", aliases=("evtechdemo#01",)) -# link to/back to hub +# link to/back to/from hub hub_room = search_object("evtechdemo#00")[0] create_object( DefaultExit, key="combat test", aliases=("combat",), location=hub_room, destination=combat_room @@ -84,3 +80,64 @@ combat_room_enemy = create_object( ) weapon_stick = create_object(EvAdventureWeapon, key="stick", attributes=(("damage_roll", "1d2"),)) combat_room_enemy.weapon = weapon_stick + + +# CODE + +# A dungeon start room for testing the dynamic dungeon generation. + +dungeon_start_room = create_object( + EvAdventureDungeonStartRoom, + key="Dungeon start room", + aliases=("evtechdemo#02",), + attributes=(("desc", "A central room, with dark exits leading to mysterious fates."),), +) +# link to/back to/from hub +hub_room = search_object("evtechdemo#00")[0] +create_object( + DefaultExit, + key="dungeon test", + aliases=("dungeon",), + location=hub_room, + destination=dungeon_start_room, +) +create_object( + DefaultExit, + key="Back to Hub", + aliases=("back", "hub"), + location=dungeon_start_room, + destination=hub_room, +) + +# add special exits out of the dungeon start room. +# These must have one of the 8 cardinal directions +# we point these exits back to the same location, which +# is what the system will use to trigger generating a new room +create_object( + EvAdventureDungeonStartRoomExit, + key="north", + aliases=("n",), + location=dungeon_start_room, + destination=dungeon_start_room, +) +create_object( + EvAdventureDungeonStartRoomExit, + key="east", + aliases=("e",), + location=dungeon_start_room, + destination=dungeon_start_room, +) +create_object( + EvAdventureDungeonStartRoomExit, + key="south", + aliases=("s",), + location=dungeon_start_room, + destination=dungeon_start_room, +) +create_object( + EvAdventureDungeonStartRoomExit, + key="west", + aliases=("w",), + location=dungeon_start_room, + destination=dungeon_start_room, +) diff --git a/evennia/contrib/tutorials/evadventure/dungeon.py b/evennia/contrib/tutorials/evadventure/dungeon.py index 717133b678..de60178862 100644 --- a/evennia/contrib/tutorials/evadventure/dungeon.py +++ b/evennia/contrib/tutorials/evadventure/dungeon.py @@ -13,9 +13,14 @@ decided to go in that direction. Each room is tagged with the specific 'instance id of that particular branch of dungon. When no characters remain in the branch, the branch is deleted. +Each room in the dungeon starts with a Tag `not_clear`; while this is set, all exits out +of the room (not the one they came from) is blocked. When whatever problem the room +offers has been solved (such as a puzzle or a battle), the tag is removed and the player(s) +can choose which exit to leave through. + """ -from datetime import datetime +from datetime import datetime, timedelta from math import sqrt from random import randint, random, shuffle @@ -25,12 +30,21 @@ from evennia.typeclasses.attributes import AttributeProperty from evennia.utils import create, search from evennia.utils.utils import inherits_from -from .rooms import EvAdventureDungeonRoom +from .rooms import EvAdventureRoom # aliases for cardinal directions +_AVAILABLE_DIRECTIONS = [ + "north", + "east", + "south", + "west", + # commented out to make the dungeon simpler to navigate + # "northeast", "southeast", "southwest", "northwest", +] + _EXIT_ALIASES = { "north": ("n",), - "east": ("w",), + "east": ("e",), "south": ("s",), "west": ("w",), "northeast": ("ne",), @@ -64,10 +78,53 @@ _EXIT_GRID_SHIFT = { # -------------------------------------------------- -# Dungeon orchestrator and rooms +# Dungeon orchestrator and room / exits # -------------------------------------------------- +class EvAdventureDungeonRoom(EvAdventureRoom): + """ + Dangerous dungeon room. + + """ + + allow_combat = True + allow_death = True + + # dungeon generation attributes; set when room is created + back_exit = AttributeProperty(None, autocreate=False) + dungeon_orchestrator = AttributeProperty(None, autocreate=False) + xy_coords = AttributeProperty(None, autocreate=False) + + @property + def is_room_clear(self): + return not bool(self.tags.get("not_clear", category="dungeon_room")) + + def clear_room(self): + self.tags.remove("not_clear", category="dungeon_room") + + def at_object_creation(self): + """ + Set the `not_clear` tag on the room. This is removed when the room is + 'cleared', whatever that means for each room. + + We put this here rather than in the room-creation code so we can override + easier (for example we may want an empty room which auto-clears). + + """ + self.tags.add("not_clear", category="dungeon_room") + + def get_display_footer(self, looker, **kwargs): + """ + Show if the room is 'cleared' or not as part of its description. + + """ + if self.is_room_clear: + return "" + else: + return "|rThe path forwards is blocked!|n" + + class EvAdventureDungeonExit(DefaultExit): """ Dungeon exit. This will not create the target room until it's traversed. @@ -80,7 +137,7 @@ class EvAdventureDungeonExit(DefaultExit): We want to block progressing forward unless the room is clear. """ - self.locks.add("traverse:not tag(not_clear, dungeon_room)") + self.locks.add("traverse:not objloctag(not_clear, dungeon_room)") def at_traverse(self, traversing_object, target_location, **kwargs): """ @@ -92,8 +149,60 @@ class EvAdventureDungeonExit(DefaultExit): self.destination = target_location = self.location.db.dungeon_orchestrator.new_room( self ) + if self.id in self.location.dungeon_orchestrator.unvisited_exits: + self.location.dungeon_orchestrator.unvisited_exits.remove(self.id) + super().at_traverse(traversing_object, target_location, **kwargs) + def at_failed_traverse(self, traversing_object, **kwargs): + """ + Called when failing to traverse. + + """ + traversing_object.msg("You can't get through this way yet!") + + +def room_generator(dungeon_orchestrator, depth, coords): + """ + Plugin room generator + + This default one returns the same empty room. + + Args: + dungeon_orchestrator (EvAdventureDungeonOrchestrator): The current orchestrator. + depth (int): The 'depth' of the dungeon (radial distance from start room) this + new room will be placed at. + coords (tuple): The `(x,y)` coords that the new room will be created at. + + """ + room_typeclass = EvAdventureDungeonRoom + + # simple map of depth to name and desc of room + name_depth_map = { + 1: ("Water-logged passage", "This earth-walled passage is dripping of water."), + 2: ("Passage with roots", "Roots are pushing through the earth walls."), + 3: ("Hardened clay passage", "The walls of this passage is of hardened clay."), + 4: ("Clay with stones", "This passage has clay with pieces of stone embedded."), + 5: ("Stone passage", "Walls are crumbling stone, with roots passing through it."), + 6: ("Stone hallway", "Walls are cut from rough stone."), + 7: ("Stone rooms", "A stone room, built from crude and heavy blocks."), + 8: ("Granite hall", "The walls are of well-fitted granite blocks."), + 9: ("Marble passages", "The walls are blank and shiny marble."), + 10: ("Furnished rooms", "The marble walls have tapestries and furnishings."), + } + key, desc = name_depth_map.get(depth, ("Dark rooms", "There is very dark here.")) + + new_room = create.create_object( + room_typeclass, + key=key, + attributes=( + ("desc", desc), + ("xy_coords", coords), + ("dungeon_orchestrator", dungeon_orchestrator), + ), + ) + return new_room + class EvAdventureDungeonOrchestrator(DefaultScript): """ @@ -104,15 +213,22 @@ class EvAdventureDungeonOrchestrator(DefaultScript): """ # this determines how branching the dungeon will be - max_unexplored_exits = 5 - max_new_exits_per_room = 3 + max_unexplored_exits = 2 + max_new_exits_per_room = 2 rooms = AttributeProperty(list()) unvisited_exits = AttributeProperty(list()) highest_depth = AttributeProperty(0) + last_updated = AttributeProperty(datetime.utcnow()) + + # the room-generator function; copied from the same-name value on the start-room when the + # orchestrator is first created + room_generator = AttributeProperty(None, autocreate=False) + # (x,y): room coordinates used up by orchestrator xy_grid = AttributeProperty(dict()) + start_room = AttributeProperty(None, autocreate=False) def register_exit_traversed(self, exit): """ @@ -136,27 +252,29 @@ class EvAdventureDungeonOrchestrator(DefaultScript): ) self.unvisited_exits.append(out_exit.id) - def _generate_dungeon_room(self, depth, coords): - # TODO - determine what type of room to create here based on location and depth - room_typeclass = EvAdventureDungeonRoom - new_room = create.create_object( - room_typeclass, - key="Dungeon room", - attributes=( - ("xy_coords", coords, "dungeon_xygrid"), - ("dungeon_orchestrator", self), - ), - ) - return new_room - def delete(self): """ Clean up the entire dungeon along with the orchestrator. """ + # first secure all characters in this branch back to the start room + characters = search.search_object_by_tag(self.key, category="dungeon_character") + start_room = self.start_room + for character in characters: + start_room.msg_contents( + "Suddenly someone stumbles out of a dark exit, covered in dust!" + ) + character.location = start_room + character.msg( + "|rAfter a long time of silence, the room suddenly rumbles and then collapses! " + "All turns dark ...|n\n\nThen you realize you are back where you started." + ) + character.tags.remove(self.key, category="dungeon_character") + # next delete all rooms in the dungeon (this will also delete exits) rooms = search.search_object_by_tag(self.key, category="dungeon_room") for room in rooms: room.delete() + # finally delete the orchestrator itself super().delete() def new_room(self, from_exit): @@ -167,11 +285,12 @@ class EvAdventureDungeonOrchestrator(DefaultScript): from_exit (Exit): The exit leading to this new room. """ + self.last_updated = datetime.utcnow() # figure out coordinate of old room and figure out what coord the # new one would get source_location = from_exit.location - x, y = source_location.attributes.get("xy_coord", category="dungeon_xygrid", default=(0, 0)) - dx, dy = _EXIT_GRID_SHIFT.get(from_exit.key, (1, 0)) + x, y = source_location.attributes.get("xy_coords", default=(0, 0)) + dx, dy = _EXIT_GRID_SHIFT.get(from_exit.key, (0, 1)) new_x, new_y = (x + dx, y + dy) # the dungeon's depth acts as a measure of the current difficulty level. This is the radial @@ -179,7 +298,7 @@ class EvAdventureDungeonOrchestrator(DefaultScript): # depth achieved. depth = int(sqrt(new_x**2 + new_y**2)) - new_room = self._generate_dungeon_room(depth, (new_x, new_y)) + new_room = self.room_generator(self, depth, (new_x, new_y)) self.xy_grid[(new_x, new_y)] = new_room @@ -210,7 +329,7 @@ class EvAdventureDungeonOrchestrator(DefaultScript): if n_exits > 1: n_exits = randint(1, n_exits) available_directions = [ - direction for direction in _EXIT_ALIASES if direction != back_exit_key + direction for direction in _AVAILABLE_DIRECTIONS if direction != back_exit_key ] # randomize order of exits shuffle(available_directions) @@ -239,7 +358,7 @@ class EvAdventureDungeonOrchestrator(DefaultScript): # -------------------------------------------------- -class EvAdventureStartRoomExit(DefaultExit): +class EvAdventureDungeonStartRoomExit(DefaultExit): """ Traversing this exit will either lead to an existing dungeon branch or create a new one. @@ -264,11 +383,18 @@ class EvAdventureStartRoomExit(DefaultExit): """ if target_location == self.location: # make a global orchestrator script for this dungeon branch + self.location.room_generator dungeon_orchestrator = create.create_script( EvAdventureDungeonOrchestrator, key=f"dungeon_orchestrator_{self.key}_{datetime.utcnow()}", + attributes=( + ("start_room", self.location), + ("room_generator", self.location.room_generator), + ), ) self.destination = target_location = dungeon_orchestrator.new_room(self) + # make sure to tag character when entering so we can find them again later + traversing_object.tags.add(dungeon_orchestrator.key, category="dungeon_character") super().at_traverse(traversing_object, target_location, **kwargs) @@ -280,7 +406,7 @@ class EvAdventureStartRoomResetter(DefaultScript): """ def at_script_creation(self): - self.key = "evadventure_startroom_resetter" + self.key = "evadventure_dungeon_startroom_resetter" def at_repeat(self): """ @@ -289,21 +415,63 @@ class EvAdventureStartRoomResetter(DefaultScript): """ room = self.obj for exi in room.exits: - if inherits_from(exi, EvAdventureStartRoomExit) and random() < 0.5: + if inherits_from(exi, EvAdventureDungeonStartRoomExit) and random() < 0.5: exi.reset_exit() -class EvAdventureDungeonRoomStart(EvAdventureDungeonRoom): +class EvAdventureDungeonBranchDeleter(DefaultScript): """ - Exits leading out of the start room, (except one leading outside) will lead to a different - dungeon-branch, and after a certain time, the given exit will instead spawn a new branch. This - room is responsible for cycling these exits regularly. + Cleanup script. After some time a dungeon branch will 'collapse', forcing all players in it + back to the start room. + + """ + + # set at creation time when the start room is created + branch_max_life = AttributeProperty(0, autocreate=False) + + def at_script_creation(self): + self.key = "evadventure_dungeon_branch_deleter" + + def at_repeat(self): + """ + Go through all dungeon-orchestrators and find which ones are too old. + + """ + max_dt = timedelta(seconds=self.branch_max_life) + max_allowed_date = datetime.utcnow() - max_dt + + for orchestrator in EvAdventureDungeonOrchestrator.objects.all(): + if orchestrator.last_updated < max_allowed_date: + # orchestrator is too old; tell it to clean up and delete itself + orchestrator.delete() + + +class EvAdventureDungeonStartRoom(EvAdventureDungeonRoom): + """ + The start room is the only permanent part of the dungeon. Exits leading from this room (except + one leading back outside) each create/links to a separate dungeon branch/instance. + + - A script will reset each exit every 5 mins; after that time, entering the exit will spawn + a new branch-instance instead of leading to the one before. + - Another script will check age of branch instance every hour; once an instance has been + inactive for a week, it will 'collapse', forcing everyone inside back to the start room. The actual exits should be created in the build script. """ - recycle_time = 5 * 60 # seconds + recycle_time = 60 * 5 # 5 mins + branch_check_time = 60 * 60 # one hour + branch_max_life = 60 * 60 * 24 * 7 # 1 week + + # allow for a custom room_generator function + room_generator = AttributeProperty(room_generator, autocreate=False) + + def get_display_footer(self, looker, **kwargs): + return ( + "|yYou sense that if you want to team up, " + "you must all pick the same path from here ... or you'll quickly get separated.|n" + ) def at_object_creation(self): # want to set the script interval on creation time, so we use create_script with obj=self @@ -311,3 +479,17 @@ class EvAdventureDungeonRoomStart(EvAdventureDungeonRoom): create.create_script( EvAdventureStartRoomResetter, obj=self, interval=self.recycle_time, autostart=True ) + create.create_script( + EvAdventureDungeonBranchDeleter, + obj=self, + interval=self.branch_check_time, + autostart=True, + attributes=(("branch_max_life", self.branch_max_life),), + ) + + def at_object_receive(self, obj, source_location, **kwargs): + """ + Make sure to clean the dungeon branch-tag from characters when leaving a dungeon branch. + + """ + obj.tags.remove(category="dungeon_character") diff --git a/evennia/contrib/tutorials/evadventure/rooms.py b/evennia/contrib/tutorials/evadventure/rooms.py index f7be9c705a..4bed2437db 100644 --- a/evennia/contrib/tutorials/evadventure/rooms.py +++ b/evennia/contrib/tutorials/evadventure/rooms.py @@ -28,38 +28,9 @@ class EvAdventurePvPRoom(DefaultRoom): allow_combat = True allow_pvp = True - -class EvAdventureDungeonRoom(EvAdventureRoom): - """ - Dangerous dungeon room. - - """ - - allow_combat = True - allow_death = True - - # dungeon generation attributes; set when room is created - back_exit = AttributeProperty(None, autocreate=False) - dungeon_orchestrator = AttributeProperty(None, autocreate=False) - xy_coords = AttributeProperty(None, autocreate=False) - - def at_object_creation(self): - """ - Set the `not_clear` tag on the room. This is removed when the room is - 'cleared', whatever that means for each room. - - We put this here rather than in the room-creation code so we can override - easier (for example we may want an empty room which auto-clears). - - """ - self.tags.add("not_clear") - def get_display_footer(self, looker, **kwargs): """ Show if the room is 'cleared' or not as part of its description. """ - if self.tags.get("not_clear", "dungeon_room"): - # this tag is cleared when the room is resolved, whatever that means. - return "|rThe path forwards is blocked!|n" - return "" + return "|yNon-lethal PvP combat is allowed here!|n" diff --git a/evennia/contrib/tutorials/evadventure/tests/test_dungeon.py b/evennia/contrib/tutorials/evadventure/tests/test_dungeon.py index ec0f94947f..a0290e8807 100644 --- a/evennia/contrib/tutorials/evadventure/tests/test_dungeon.py +++ b/evennia/contrib/tutorials/evadventure/tests/test_dungeon.py @@ -27,7 +27,7 @@ class TestDungeon(EvAdventureMixin, BaseEvenniaTest): """ super().setUp() - droomclass = dungeon.EvAdventureDungeonRoomStart + droomclass = dungeon.EvAdventureDungeonStartRoom droomclass.recycle_time = 0 # disable the tick self.start_room = create_object(droomclass, key="bottom of well") @@ -36,14 +36,14 @@ class TestDungeon(EvAdventureMixin, BaseEvenniaTest): self.start_room.scripts.get("evadventure_startroom_resetter")[0].interval, -1 ) self.start_north = create_object( - dungeon.EvAdventureStartRoomExit, + dungeon.EvAdventureDungeonStartRoomExit, key="north", location=self.start_room, destination=self.start_room, ) self.start_north self.start_south = create_object( - dungeon.EvAdventureStartRoomExit, + dungeon.EvAdventureDungeonStartRoomExit, key="south", location=self.start_room, destination=self.start_room, diff --git a/evennia/locks/lockfuncs.py b/evennia/locks/lockfuncs.py index b3d3de0a5e..da4d47b03f 100644 --- a/evennia/locks/lockfuncs.py +++ b/evennia/locks/lockfuncs.py @@ -15,6 +15,7 @@ a certain object type. from ast import literal_eval + from django.conf import settings from evennia.utils import utils @@ -466,6 +467,7 @@ def tag(accessing_obj, accessed_obj, *args, **kwargs): category. If accessing_obj has the ".obj" property (such as is the case for a command), then accessing_obj.obj is used instead. + """ if hasattr(accessing_obj, "obj"): accessing_obj = accessing_obj.obj @@ -474,6 +476,34 @@ def tag(accessing_obj, accessed_obj, *args, **kwargs): return bool(accessing_obj.tags.get(tagkey, category=category)) +def objtag(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + objtag(tagkey) + objtag(tagkey, category): + + Only true if `accessed_obj` has the given tag and optional category. + + """ + return tag(accessed_obj, None, *args, **kwargs) + + +def objloctag(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + objloctag(tagkey) + objloctag(tagkey, category): + + Only true if `accessed_obj.location` has the given tag and optional category. + If obj has no location, this lockfunc fails. + + """ + try: + return tag(accessed_obj.location, None, *args, **kwargs) + except AttributeError: + return False + + def is_ooc(accessing_obj, accessed_obj, *args, **kwargs): """ Usage: