Start adding dungeon logic

This commit is contained in:
Griatch 2022-07-19 00:58:31 +02:00
parent 73d8f24b7c
commit 104f47860f
4 changed files with 623 additions and 26 deletions

View file

@ -0,0 +1,262 @@
"""
Dungeon system
This creates a procedurally generated dungeon.
The dungone originates in an entrance room with exits that spawn a new dungeon connection every X
minutes. As long as characters go through the same exit within that time, they will all end up in
the same dungeon 'branch', otherwise they will go into separate, un-connected dungeon 'branches'.
They can always go back to the start room, but this will become a one-way exit back.
When moving through the dungeon, a new room is not generated until characters
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.
"""
from datetime import datetime
from math import sqrt
from random import randint, random, shuffle
from evennia import AttributeProperty, DefaultExit, DefaultScript
from evennia.utils import create
from evennia.utils.utils import inherits_from
from .rooms import EvAdventureDungeonRoom
# aliases for cardinal directions
_EXIT_ALIASES = {
"north": ("n",),
"east": ("w",),
"south": ("s",),
"west": ("w",),
"northeast": ("ne",),
"southeast": ("se",),
"southwest": ("sw",),
"northwest": ("nw",),
}
# finding the reverse cardinal direction
_EXIT_REVERSE_MAPPING = {
"north": "south",
"east": "west",
"south": "north",
"west": "east",
"northeast": "southwest",
"southeast": "northwest",
"southwest": "northeast",
"northwest": "southeast",
}
# how xy coordinate shifts by going in direction
_EXIT_GRID_SHIFT = {
"north": (0, 1),
"east": (1, 0),
"south": (0, -1),
"west": (-1, 0),
"northeast": (1, 1),
"southeast": (1, -1),
"southwest": (-1, -1),
"northwest": (-1, 1),
}
# --------------------------------------------------
# Dungeon orchestrator and rooms
# --------------------------------------------------
class EvAdventureDungeonExit(DefaultExit):
"""
Dungeon exit. This will not create the target room until it's traversed.
It must be created referencing the dungeon_orchestrator it belongs to.
"""
dungeon_orchestrator = AttributeProperty(None, autocreate=False)
def at_traverse(self, traversing_object, target_location, **kwargs):
"""
Called when traversing. `target_location` will be None if the
target was not yet created.
"""
if not target_location:
self.destination = target_location = self.dungeon_orchestrator.new_room(self)
super().at_traverse(traversing_object, target_location, **kwargs)
class EvAdventureDungeonOrchestrator(DefaultScript):
"""
One script is created per dungeon 'branch' created. The orchestrator is
responsible for determining what is created next when a character enters an
exit within the dungeon.
"""
# this determines how branching the dungeon will be
max_unexplored_exits = 5
max_new_exits_per_room = 3
rooms = AttributeProperty(list())
n_unvisited_exits = AttributeProperty(list())
highest_depth = AttributeProperty(0)
# (x,y): room
xy_grid = AttributeProperty(dict())
def register_exit_traversed(self, exit):
"""
Tell the system the given exit was traversed. This allows us to track how many unvisited
paths we have so as to not have it grow exponentially.
"""
if exit.id in self.unvisited_exits:
self.unvisited_exits.remove(exit.id)
def create_out_exit(self, location, exit_direction="north"):
"""
Create outgoing exit from a room. The target room is not yet created.
"""
out_exit, _ = EvAdventureDungeonExit.create(
key=exit_direction, location=location, aliases=_EXIT_ALIASES[exit_direction]
)
self.unvisited_exits.append(out_exit.id)
def _generate_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",
tags=((self.key,),),
attributes=(("xy_coord", coords, "dungeon_xygrid"),),
)
return new_room
def new_room(self, from_exit):
"""
Create a new Dungeon room leading from the provided exit.
"""
# 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.get("xy_coord", category="dungeon_xygrid", default=(0, 0))
dx, dy = _EXIT_GRID_SHIFT.get(from_exit.key, (1, 0))
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
# distance from the (0, 0) (the entrance). The Orchestrator also tracks the highest
# depth achieved.
depth = int(sqrt(new_x**2 + new_y**2))
new_room = self._generate_room(depth, (new_x, new_y))
self.xy_grid[(new_x, new_y)] = new_room
# always make a return exit back to where we came from
back_exit_key = (_EXIT_REVERSE_MAPPING.get(from_exit.key, "back"),)
EvAdventureDungeonExit(
key=back_exit_key,
aliases=_EXIT_ALIASES.get(back_exit_key, ()),
location=new_room,
destination=from_exit.location,
attributes=(("desc", "A dark passage."),),
)
# figure out what other exits should be here, if any
n_unexplored = len(self.unvisited_exits)
if n_unexplored >= self.max_unexplored_exits:
# no more exits to open - this is a dead end.
return
else:
n_exits = randint(1, min(self.max_new_exits_per_room, n_unexplored))
back_exit = from_exit.key
available_directions = [
direction for direction in _EXIT_ALIASES if direction != back_exit
]
# randomize order of exits
shuffle(available_directions)
for _ in range(n_exits):
while available_directions:
# get a random direction and check so there isn't a room already
# created in that direction
direction = available_directions.pop(0)
dx, dy = _EXIT_GRID_SHIFT(direction)
target_coord = (new_x + dx, new_y + dy)
if target_coord not in self.xy_grid:
# no room there - make an exit to it
self.create_out_exit(new_room, direction)
break
self.highest_depth = max(self.highest_depth, depth)
# --------------------------------------------------
# Start room
# --------------------------------------------------
class EvAdventureStartRoomExit(DefaultExit):
"""
Traversing this exit will either lead to an existing dungeon branch or create
a new one.
"""
dungeon_orchestrator = AttributeProperty(None, autocreate=False)
def reset_exit(self):
"""
Flush the exit, so next traversal creates a new dungeon branch.
"""
self.dungeon_orchestrator = self.destination = None
def at_traverse(self, traversing_object, target_location, **kwargs):
"""
When traversing create a new orchestrator if one is not already assigned.
"""
if target_location is None or self.dungeon_orchestrator is None:
self.dungeon_orchestrator, _ = EvAdventureDungeonOrchestrator.create(
f"dungeon_orchestrator_{datetime.utcnow()}",
)
target_location = self.destination = self.dungeon_orchestrator.new_room(self)
super().at_traverse(traversing_object, target_location, **kwargs)
class EvAdventureStartRoomResetter(DefaultScript):
"""
Simple ticker-script. Introduces a chance of the room's exits cycling every interval.
"""
def at_repeat(self):
"""
Called every time the script repeats.
"""
room = self.obj
for exi in room.exits:
if inherits_from(exi, EvAdventureStartRoomExit) and random() < 0.5:
exi.reset_exit()
class EvAdventureDungeonRoomStart(EvAdventureDungeonRoom):
"""
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.
The actual exits should be created in the build script.
"""
recycle_time = 5 * 60 # seconds
def at_object_creation(self):
self.scripts.add(EvAdventureStartRoomResetter, interval=self.recycle_time, autostart=True)

View file

@ -0,0 +1,335 @@
"""
Knave has a system of Slots for its inventory.
"""
from .enums import Ability, WieldLocation
from .objects import WeaponEmptyHand
class EquipmentError(TypeError):
pass
class EquipmentHandler:
"""
_Knave_ puts a lot of emphasis on the inventory. You have CON_DEFENSE inventory
slots. Some things, like torches can fit multiple in one slot, other (like
big weapons and armor) use more than one slot. The items carried and wielded has a big impact
on character customization - even magic requires carrying a runestone per spell.
The inventory also doubles as a measure of negative effects. Getting soaked in mud
or slime could gunk up some of your inventory slots and make the items there unusuable
until you clean them.
"""
save_attribute = "inventory_slots"
def __init__(self, obj):
self.obj = obj
self._load()
def _load(self):
"""
Load or create a new slot storage.
"""
self.slots = self.obj.attributes.get(
self.save_attribute,
category="inventory",
default={
WieldLocation.WEAPON_HAND: None,
WieldLocation.SHIELD_HAND: None,
WieldLocation.TWO_HANDS: None,
WieldLocation.BODY: None,
WieldLocation.HEAD: None,
WieldLocation.BACKPACK: [],
},
)
def _save(self):
"""
Save slot to storage.
"""
self.obj.attributes.add(self.save_attribute, self.slots, category="inventory")
def _count_slots(self):
"""
Count slot usage. This is fetched from the .size Attribute of the
object. The size can also be partial slots.
"""
slots = self.slots
wield_usage = sum(
getattr(slotobj, "size", 0) or 0
for slot, slotobj in slots.items()
if slot is not WieldLocation.BACKPACK
)
backpack_usage = sum(
getattr(slotobj, "size", 0) or 0 for slotobj in slots[WieldLocation.BACKPACK]
)
return wield_usage + backpack_usage
@property
def max_slots(self):
"""
The max amount of equipment slots ('carrying capacity') is based on
the constitution defense.
"""
return getattr(self.obj, Ability.CON.value, 1) + 10
def validate_slot_usage(self, obj):
"""
Check if obj can fit in equipment, based on its size.
Args:
obj (EvAdventureObject): The object to add.
Raise:
EquipmentError: If there's not enough room.
"""
size = getattr(obj, "size", 0)
max_slots = self.max_slots
current_slot_usage = self._count_slots()
if current_slot_usage + size > max_slots:
slots_left = max_slots - current_slot_usage
raise EquipmentError(
f"Equipment full ($int2str({slots_left}) slots "
f"remaining, {obj.key} needs $int2str({size}) "
f"$pluralize(slot, {size}))."
)
return True
@property
def armor(self):
"""
Armor provided by actually worn equipment/shield. For body armor
this is a base value, like 12, for shield/helmet, it's a bonus, like +1.
We treat values and bonuses equal and just add them up. This value
can thus be 0, the 'unarmored' default should be handled by the calling
method.
Returns:
int: Armor from equipment. Note that this is the +bonus of Armor, not the
'defense' (to get that one adds 10).
"""
slots = self.slots
return sum(
(
# armor is listed using its defense, so we remove 10 from it
# (11 is base no-armor value in Knave)
getattr(slots[WieldLocation.BODY], "armor", 11) - 10,
# shields and helmets are listed by their bonus to armor
getattr(slots[WieldLocation.SHIELD_HAND], "armor", 0),
getattr(slots[WieldLocation.HEAD], "armor", 0),
)
)
@property
def weapon(self):
"""
Conveniently get the currently active weapon or rune stone.
Returns:
obj or None: The weapon. None if unarmored.
"""
# first checks two-handed wield, then one-handed; the two
# should never appear simultaneously anyhow (checked in `use` method).
slots = self.slots
weapon = slots[WieldLocation.TWO_HANDS]
if not weapon:
weapon = slots[WieldLocation.WEAPON_HAND]
if not weapon:
weapon = WeaponEmptyHand()
return weapon
def display_loadout(self):
"""
Get a visual representation of your current loadout.
Returns:
str: The current loadout.
"""
slots = self.slots
weapon_str = "You are fighting with your bare fists"
shield_str = " and have no shield."
armor_str = "You wear no armor"
helmet_str = " and no helmet."
two_hands = slots[WieldLocation.TWO_HANDS]
if two_hands:
weapon_str = f"You wield {two_hands} with both hands"
shield_str = " (you can't hold a shield at the same time)."
else:
one_hands = slots[WieldLocation.WEAPON_HAND]
if one_hands:
weapon_str = f"You are wielding {one_hands} in one hand."
shield = slots[WieldLocation.SHIELD_HAND]
if shield:
shield_str = f"You have {shield} in your off hand."
armor = slots[WieldLocation.BODY]
if armor:
armor_str = f"You are wearing {armor}"
helmet = slots[WieldLocation.BODY]
if helmet:
helmet_str = f" and {helmet} on your head."
return f"{weapon_str}{shield_str}\n{armor_str}{helmet_str}"
def use(self, obj):
"""
Make use of item - this makes use of the object's wield slot to decide where
it goes. If it doesn't have any, it goes into backpack.
Args:
obj (EvAdventureObject): Thing to use.
Raises:
EquipmentError: If there's no room in inventory. It will contains the details
of the error, suitable to echo to user.
Notes:
If using an item already in the backpack, it should first be `removed` from the
backpack, before applying here - otherwise, it will be added a second time!
this will cleanly move any 'colliding' items to the backpack to
make the use possible (such as moving sword + shield to backpack when wielding
a two-handed weapon). If wanting to warn the user about this, it needs to happen
before this call.
"""
# first check if we have room for this
self.validate_slot_usage(obj)
slots = self.slots
use_slot = getattr(obj, "inventory_use_slot", WieldLocation.BACKPACK)
if use_slot is WieldLocation.TWO_HANDS:
# two-handed weapons can't co-exist with weapon/shield-hand used items
slots[WieldLocation.WEAPON_HAND] = slots[WieldLocation.SHIELD_HAND] = None
slots[use_slot] = obj
elif use_slot in (WieldLocation.WEAPON_HAND, WieldLocation.SHIELD_HAND):
# can't keep a two-handed weapon if adding a one-handede weapon or shield
slots[WieldLocation.TWO_HANDS] = None
slots[use_slot] = obj
elif use_slot is WieldLocation.BACKPACK:
# backpack has multiple slots.
slots[use_slot].append(obj)
else:
# for others (body, head), just replace whatever's there
slots[use_slot] = obj
# store new state
self._save()
def add(self, obj):
"""
Put something in the backpack specifically (even if it could be wield/worn).
"""
# check if we have room
self.validate_slot_usage(obj)
self.slots[WieldLocation.BACKPACK].append(obj)
self._save()
def can_remove(self, leaving_object):
"""
Called to check if the object can be removed.
"""
return True # TODO - some things may not be so easy, like mud
def remove(self, obj_or_slot):
"""
Remove specific object or objects from a slot.
Args:
obj_or_slot (EvAdventureObject or WieldLocation): The specific object or
location to empty. If this is WieldLocation.BACKPACK, all items
in the backpack will be emptied and returned!
Returns:
list: A list of 0, 1 or more objects emptied from the inventory.
"""
slots = self.slots
ret = []
if isinstance(obj_or_slot, WieldLocation):
if obj_or_slot is WieldLocation.BACKPACK:
# empty entire backpack
ret.extend(slots[obj_or_slot])
slots[obj_or_slot] = []
else:
ret.append(slots[obj_or_slot])
slots[obj_or_slot] = None
elif obj_or_slot in self.slots.values():
# obj in use/wear slot
for slot, objslot in slots.items():
if objslot is obj_or_slot:
slots[slot] = None
ret.append(objslot)
elif obj_or_slot in slots[WieldLocation.BACKPACK]:
# obj in backpack slot
try:
slots[WieldLocation.BACKPACK].remove(obj_or_slot)
ret.append(obj_or_slot)
except ValueError:
pass
if ret:
self._save()
return ret
def get_wieldable_objects_from_backpack(self):
"""
Get all wieldable weapons (or spell runes) from backpack. This is useful in order to
have a list to select from when swapping your wielded loadout.
Returns:
list: A list of objects with a suitable `inventory_use_slot`. We don't check
quality, so this may include broken items (we may want to visually show them
in the list after all).
"""
return [
obj
for obj in self.slots[WieldLocation.BACKPACK]
if obj.inventory_use_slot
in (WieldLocation.WEAPON_HAND, WieldLocation.TWO_HANDS, WieldLocation.SHIELD_HAND)
]
def get_wearable_objects_from_backpack(self):
"""
Get all wearable items (armor or helmets) from backpack. This is useful in order to
have a list to select from when swapping your worn loadout.
Returns:
list: A list of objects with a suitable `inventory_use_slot`. We don't check
quality, so this may include broken items (we may want to visually show them
in the list after all).
"""
return [
obj
for obj in self.slots[WieldLocation.BACKPACK]
if obj.inventory_use_slot in (WieldLocation.BODY, WieldLocation.HEAD)
]
def get_usable_objects_from_backpack(self):
"""
Get all 'usable' items (like potions) from backpack. This is useful for getting a
list to select from.
Returns:
list: A list of objects that are usable.
"""
character = self.obj
return [obj for obj in self.slots[WieldLocation.BACKPACK] if obj.at_pre_use(character)]

View file

@ -532,7 +532,7 @@ class EvAdventureCharacterGeneration:
for item in self.backpack:
# TODO create here
character.equipment.store(item)
character.equipment.add(item)
# character improvement

View file

@ -3,15 +3,14 @@ Test the rules and chargen.
"""
from unittest.mock import patch, MagicMock, call
from parameterized import parameterized
from evennia.utils.test_resources import BaseEvenniaTest
from unittest.mock import MagicMock, call, patch
from anything import Something
from evennia.utils.test_resources import BaseEvenniaTest
from parameterized import parameterized
from .. import characters, enums, equipment, random_tables, rules
from .mixins import EvAdventureMixin
from .. import rules
from .. import enums
from .. import random_tables
from .. import characters
class EvAdventureRollEngineTest(BaseEvenniaTest):
@ -86,23 +85,24 @@ class EvAdventureRollEngineTest(BaseEvenniaTest):
character.dexterity = 1
self.assertEqual(
self.roll_engine.saving_throw(character, bonus_type=enums.Ability.STR), (False, None)
self.roll_engine.saving_throw(character, bonus_type=enums.Ability.STR),
(False, None, Something),
)
self.assertEqual(
self.roll_engine.saving_throw(character, bonus_type=enums.Ability.DEX, modifier=1),
(False, None),
(False, None, Something),
)
self.assertEqual(
self.roll_engine.saving_throw(
character, advantage=True, bonus_type=enums.Ability.DEX, modifier=6
),
(False, None),
(False, None, Something),
)
self.assertEqual(
self.roll_engine.saving_throw(
character, disadvantage=True, bonus_type=enums.Ability.DEX, modifier=7
),
(True, None),
(True, None, Something),
)
mock_randint.return_value = 1
@ -110,7 +110,7 @@ class EvAdventureRollEngineTest(BaseEvenniaTest):
self.roll_engine.saving_throw(
character, disadvantage=True, bonus_type=enums.Ability.STR, modifier=2
),
(False, enums.Ability.CRITICAL_FAILURE),
(False, enums.Ability.CRITICAL_FAILURE, Something),
)
mock_randint.return_value = 20
@ -118,7 +118,7 @@ class EvAdventureRollEngineTest(BaseEvenniaTest):
self.roll_engine.saving_throw(
character, disadvantage=True, bonus_type=enums.Ability.STR, modifier=2
),
(True, enums.Ability.CRITICAL_SUCCESS),
(True, enums.Ability.CRITICAL_SUCCESS, Something),
)
@patch("evennia.contrib.tutorials.evadventure.rules.randint")
@ -133,7 +133,7 @@ class EvAdventureRollEngineTest(BaseEvenniaTest):
self.roll_engine.opposed_saving_throw(
attacker, defender, attack_type=enums.Ability.STR, defense_type=enums.Ability.ARMOR
),
(False, None),
(False, None, Something),
)
self.assertEqual(
self.roll_engine.opposed_saving_throw(
@ -143,7 +143,7 @@ class EvAdventureRollEngineTest(BaseEvenniaTest):
defense_type=enums.Ability.ARMOR,
modifier=2,
),
(True, None),
(True, None, Something),
)
@patch("evennia.contrib.tutorials.evadventure.rules.randint")
@ -224,7 +224,7 @@ class EvAdventureRollEngineTest(BaseEvenniaTest):
# death
mock_randint.return_value = 1
self.roll_engine.roll_death(character)
character.handle_death.assert_called()
character.at_death.assert_called()
# strength loss
mock_randint.return_value = 3
self.roll_engine.roll_death(character)
@ -310,7 +310,7 @@ class EvAdventureCharacterGenerationTest(BaseEvenniaTest):
self.assertTrue(character.db.desc.startswith("Herbalist"))
self.assertEqual(character.armor, "gambeson")
character.equipment.store.assert_called()
character.equipment.add.assert_called()
class EvAdventureEquipmentTest(EvAdventureMixin, BaseEvenniaTest):
@ -350,7 +350,7 @@ class EvAdventureEquipmentTest(EvAdventureMixin, BaseEvenniaTest):
if is_ok:
self.assertTrue(self.character.equipment.validate_slot_usage(obj))
else:
with self.assertRaises(characters.EquipmentError):
with self.assertRaises(equipment.EquipmentError):
self.character.equipment.validate_slot_usage(obj)
@parameterized.expand(
@ -375,8 +375,8 @@ class EvAdventureEquipmentTest(EvAdventureMixin, BaseEvenniaTest):
else:
self.assertEqual(self.character.equipment.slots[where], obj)
def test_store(self):
self.character.equipment.store(self.weapon)
def test_add(self):
self.character.equipment.add(self.weapon)
self.assertEqual(self.character.equipment.slots[enums.WieldLocation.WEAPON_HAND], None)
self.assertTrue(self.weapon in self.character.equipment.slots[enums.WieldLocation.BACKPACK])
@ -408,7 +408,7 @@ class EvAdventureEquipmentTest(EvAdventureMixin, BaseEvenniaTest):
def test_remove__with_obj(self):
self.character.equipment.use(self.shield)
self.character.equipment.use(self.item)
self.character.equipment.store(self.weapon)
self.character.equipment.add(self.weapon)
self.assertEqual(
self.character.equipment.slots[enums.WieldLocation.SHIELD_HAND], self.shield
@ -428,7 +428,7 @@ class EvAdventureEquipmentTest(EvAdventureMixin, BaseEvenniaTest):
def test_remove__with_slot(self):
self.character.equipment.use(self.shield)
self.character.equipment.use(self.item)
self.character.equipment.store(self.helmet)
self.character.equipment.add(self.helmet)
self.assertEqual(
self.character.equipment.slots[enums.WieldLocation.SHIELD_HAND], self.shield
@ -449,11 +449,11 @@ class EvAdventureEquipmentTest(EvAdventureMixin, BaseEvenniaTest):
def test_properties(self):
self.character.equipment.use(self.armor)
self.assertEqual(self.character.equipment.armor, 11)
self.assertEqual(self.character.equipment.armor, 1)
self.character.equipment.use(self.shield)
self.assertEqual(self.character.equipment.armor, 12)
self.assertEqual(self.character.equipment.armor, 2)
self.character.equipment.use(self.helmet)
self.assertEqual(self.character.equipment.armor, 13)
self.assertEqual(self.character.equipment.armor, 3)
self.character.equipment.use(self.weapon)
self.assertEqual(self.character.equipment.weapon, self.weapon)