mirror of
https://github.com/evennia/evennia.git
synced 2026-03-16 21:06:30 +01:00
Continue with evadventure implementation
This commit is contained in:
parent
a07ef8e3c4
commit
a553f1ab2f
14 changed files with 1296 additions and 190 deletions
|
|
@ -160,6 +160,10 @@ Up requirements to Django 4.0+, Twisted 22+, Python 3.9 or 3.10
|
|||
- Attribute storage support defaultdics (Hendher)
|
||||
- Add ObjectParent mixin to default game folder template as an easy, ready-made
|
||||
way to override features on all ObjectDB-inheriting objects easily.
|
||||
- New `at_pre_object_receive(obj, source_location)` method on Objects. Called on
|
||||
destination, mimicking behavior of `at_pre_move` hook - returning False will abort move.
|
||||
- New `at_pre_object_leave(obj, destination)` method on Objects. Called on
|
||||
source location, mimicking behavior of `at_pre_move` hook - returning False will abort move.
|
||||
|
||||
|
||||
## Evennia 0.9.5
|
||||
|
|
|
|||
|
|
@ -0,0 +1,423 @@
|
|||
"""
|
||||
Base Character and NPCs.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
from evennia.objects.objects import DefaultCharacter, DefaultObject
|
||||
from evennia.typeclasses.attributes import AttributeProperty
|
||||
from evennia.utils.utils import lazy_property, int2str
|
||||
from .objects import EvAdventureObject
|
||||
|
||||
|
||||
class EquipmentError(TypeError):
|
||||
pass
|
||||
|
||||
|
||||
class EquipmentHandler:
|
||||
"""
|
||||
_Knave_ puts a lot of emphasis on the inventory. You have 20 inventory slots,
|
||||
Some things, like torches can fit multiple in one slot, other (like
|
||||
big weapons) 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 cleaned them.
|
||||
|
||||
"""
|
||||
# these are the equipment slots available
|
||||
total_slots = 20
|
||||
wield_slots = ["shield", "weapon"]
|
||||
wear_slots = ["helmet", "armor"]
|
||||
|
||||
def __init__(self, obj):
|
||||
self.obj = obj
|
||||
self._slots_used = None
|
||||
self._wielded = None
|
||||
self._worn = None
|
||||
self._armor = None
|
||||
|
||||
def _wield_or_wear(self, item, action="wear"):
|
||||
"""
|
||||
Wield or wear a previously carried item in one of the supported wield/wear slots. Items need
|
||||
to have the wieldable/wearable tag and will get a wielded/worn tag. The slot to occupy is
|
||||
retrieved from the item itself.
|
||||
|
||||
Args:
|
||||
item (Object): The object to wield. This will replace any existing
|
||||
wieldable item in that spot.
|
||||
action (str): One of 'wield' or 'wear'.
|
||||
Returns:
|
||||
tuple: (slot, old_item - the slot-name this item was
|
||||
assigned to (like 'helmet') and any old item that was replaced in that location,.
|
||||
(else `old_item` is `None`). This is useful for returning info messages
|
||||
to the user.
|
||||
Raises:
|
||||
EquipmentError: If there is a problem wielding the item.
|
||||
|
||||
Notes:
|
||||
Since the action of wielding is so similar to wearing, we use the same code for both,
|
||||
just exchanging which slot to use and the wield/wear and wielded/worn texts.
|
||||
|
||||
"""
|
||||
adjective = 'wearable' if action == 'wear' else 'wieldable'
|
||||
verb = "worn" if action == 'wear' else 'wielded'
|
||||
|
||||
if item not in self.obj.contents:
|
||||
raise EquipmentError(f"You need to pick it up before you can use it.")
|
||||
if item in self.wielded:
|
||||
raise EquipmentError(f"Already using {item.key}")
|
||||
if not item.tags.has(adjective, category="inventory"):
|
||||
# must have wieldable/wearable tag
|
||||
raise EquipmentError(f"Cannot {action} {item.key}")
|
||||
|
||||
# see if an existing item already sits in the relevant slot
|
||||
if action == 'wear':
|
||||
slot = item.wear_slot
|
||||
old_item = self.worn.get(slot)
|
||||
self.worn[slot] = item
|
||||
else:
|
||||
slot = item.wield_slot
|
||||
old_item = self.wielded.get(slot)
|
||||
self.wielded[item]
|
||||
|
||||
# untag old, tag the new and store it in .wielded dict for easy access
|
||||
if old_item:
|
||||
old_item.tags.remove(verb, category="inventory")
|
||||
item.tags.add(verb, category="inventory")
|
||||
|
||||
return slot, old_item
|
||||
|
||||
@property
|
||||
def slots_used(self):
|
||||
"""
|
||||
Return how many slots are used up (out of .total_slots). Certain, big items may use more
|
||||
than one slot. Also caches the results.
|
||||
|
||||
"""
|
||||
slots_used = self._slots_used
|
||||
if slots_used is None:
|
||||
slots_used = self._slots_used = sum(
|
||||
item.inventory_slot_usage for item in self.contents
|
||||
)
|
||||
return slots_used
|
||||
|
||||
@property
|
||||
def all(self):
|
||||
"""
|
||||
Get all carried items. Used by an 'inventory' command.
|
||||
|
||||
"""
|
||||
return self.obj.contents
|
||||
|
||||
@property
|
||||
def worn(self):
|
||||
"""
|
||||
Get (and cache) all worn items.
|
||||
|
||||
"""
|
||||
worn = self._worn
|
||||
if worn is None:
|
||||
worn = self._worn = list(
|
||||
DefaultObject.objects
|
||||
.get_by_tag(["wearable", "worn"], category="inventory")
|
||||
.filter(db_location=self.obj)
|
||||
)
|
||||
return worn
|
||||
|
||||
@property
|
||||
def wielded(self):
|
||||
wielded = self._wielded
|
||||
if wielded is None:
|
||||
wielded = self._wielded = list(
|
||||
DefaultObject.objects
|
||||
.get_by_tag(["wieldable", "wielded"], category="inventory")
|
||||
.filter(db_location=self.obj)
|
||||
)
|
||||
return wielded
|
||||
|
||||
@property
|
||||
def carried(self):
|
||||
wielded_or_worn = self.wielded + self.worn
|
||||
return [item for item in self.contents if item not in wielded_or_worn]
|
||||
|
||||
@property
|
||||
def armor_defense(self):
|
||||
"""
|
||||
Figure out the total armor defense of the character. This is a combination
|
||||
of armor from worn items (helmets, armor) and wielded ones (shields).
|
||||
|
||||
"""
|
||||
armor = self._armor
|
||||
if armor is None:
|
||||
# recalculate and refresh cache. Default for unarmored enemy is armor defense of 11.
|
||||
armor = self._armor = sum(item.armor for item in self.worn + self.wielded) or 11
|
||||
return armor
|
||||
|
||||
def has_space(self, item):
|
||||
"""
|
||||
Check if there's room in equipment for this item.
|
||||
|
||||
Args:
|
||||
item (Object): An entity that takes up space.
|
||||
|
||||
Returns:
|
||||
bool: If there's room or not.
|
||||
|
||||
Notes:
|
||||
Also informs the user of the failure.
|
||||
|
||||
"""
|
||||
needed_slots = getattr(item, "inventory_slot_usage", 1)
|
||||
free = self.slots_used - needed_slots
|
||||
if free - needed_slots < 0:
|
||||
self.obj.msg(f"No space in inventory - {item} takes up {needed_slots}, "
|
||||
f"but $int2str({free}) $pluralize(is, {free}, are) available.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def can_drop(self, item):
|
||||
"""
|
||||
Check if the item can be dropped - this is blocked by being worn or wielded.
|
||||
|
||||
Args:
|
||||
item (Object): The item to drop.
|
||||
|
||||
Returns:
|
||||
bool: If the object can be dropped.
|
||||
|
||||
Notes:
|
||||
Informs the user of a failure.
|
||||
|
||||
"""
|
||||
if item in self.wielded:
|
||||
self.msg("You are currently wielding {item.key}. Unwield it first.")
|
||||
return False
|
||||
if item in self.worn:
|
||||
self.msg("You are currently wearing {item.key}. Remove it first.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def add(self, item):
|
||||
"""
|
||||
Add an item to the inventory. This will be called when picking something up. An item
|
||||
must be carried before it can be worn or wielded.
|
||||
|
||||
There is a max number of carry-slots.
|
||||
|
||||
Args:
|
||||
item (EvAdventureObject): The item to add (pick up).
|
||||
Raises:
|
||||
EquipmentError: If the item can't be added (usually because of lack of space).
|
||||
|
||||
"""
|
||||
slots_needed = item.inventory_slot_usage
|
||||
slots_already_used = self.slots_used
|
||||
|
||||
slots_free = self.total_slots - slots_already_used
|
||||
|
||||
if slot_needed > slots_free:
|
||||
raise EquipmentError(
|
||||
f"This requires {slots_needed} equipment slots - you have "
|
||||
f"$int2str({slots_free}) $pluralize(slot, {slots_free}) available.")
|
||||
# move to inventory
|
||||
item.location = self.obj
|
||||
self.slots_used += slots_needed
|
||||
|
||||
def remove(self, item):
|
||||
"""
|
||||
Remove (drop) an item from inventory. This will also un-wear or un-wield it.
|
||||
|
||||
Args:
|
||||
item (EvAdventureObject): The item to drop.
|
||||
Raises:
|
||||
EquipmentError: If the item can't be dropped (usually because we don't have it).
|
||||
|
||||
"""
|
||||
if item not in self.obj.contents:
|
||||
raise EquipmentError("You are not carrying this item.")
|
||||
self.slots_used -= item.inventory_slot_usage
|
||||
|
||||
def wear(self, item):
|
||||
"""
|
||||
Wear a previously carried item. The item itelf knows which slot it belongs in (like 'helmet'
|
||||
or 'armor').
|
||||
|
||||
Args:
|
||||
item (EvAdventureObject): The item to wear. Must already be carried.
|
||||
Returns:
|
||||
tuple: (slot, old_item - the slot-name this item was
|
||||
assigned to (like 'helmet') and any old item that was replaced in that location
|
||||
(else `old_item` is `None`). This is useful for returning info messages
|
||||
to the user.
|
||||
Raises:
|
||||
EquipmentError: If there is a problem wearing the item.
|
||||
|
||||
"""
|
||||
return self._wield_or_wear(item, action="wield")
|
||||
|
||||
def wield(self, item):
|
||||
"""
|
||||
Wield a previously carried item. The item itelf knows which wield-slot it belongs in (like
|
||||
'helmet' or 'armor').
|
||||
|
||||
Args:
|
||||
item (EvAdventureObject): The item to wield. Must already be carried.
|
||||
|
||||
Returns:
|
||||
tuple: (slot, old_item - the wield-slot-name this item was
|
||||
assigned to (like 'shield') and any old item that was replaced in that location
|
||||
(else `old_item` is `None`). This is useful for returning info messages
|
||||
to the user.
|
||||
Raises:
|
||||
EquipmentError: If there is a problem wielding the item.
|
||||
|
||||
"""
|
||||
return self._wield_or_wear(item, action="wear")
|
||||
|
||||
|
||||
class EvAdventureCharacter(DefaultCharacter):
|
||||
"""
|
||||
A Character for use with EvAdventure. This also works fine for
|
||||
monsters and NPCS.
|
||||
|
||||
"""
|
||||
|
||||
strength = AttributeProperty(default=1)
|
||||
dexterity = AttributeProperty(default=1)
|
||||
constitution = AttributeProperty(default=1)
|
||||
intelligence = AttributeProperty(default=1)
|
||||
wisdom = AttributeProperty(default=1)
|
||||
charisma = AttributeProperty(default=1)
|
||||
|
||||
armor = AttributeProperty(default=1)
|
||||
|
||||
exploration_speed = AttributeProperty(default=120)
|
||||
combat_speed = AttributeProperty(default=40)
|
||||
|
||||
hp = AttributeProperty(default=4)
|
||||
hp_max = AttributeProperty(default=4)
|
||||
level = AttributeProperty(default=1)
|
||||
xp = AttributeProperty(default=0)
|
||||
|
||||
morale = AttributeProperty(default=9) # only used for NPC/monster morale checks
|
||||
|
||||
@lazy_property
|
||||
def equipment(self):
|
||||
"""Allows to access equipment like char.equipment.worn"""
|
||||
return EquipmentHandler(self)
|
||||
|
||||
def at_pre_object_receive(self, moved_object, source_location, **kwargs):
|
||||
"""
|
||||
Hook called by Evennia before moving an object here. Return False to abort move.
|
||||
|
||||
Args:
|
||||
moved_object (Object): Object to move into this one (that is, into inventory).
|
||||
source_location (Object): Source location moved from.
|
||||
**kwargs: Passed from move operation; unused here.
|
||||
|
||||
Returns:
|
||||
bool: If move should be allowed or not.
|
||||
|
||||
"""
|
||||
return self.equipment.has_space(moved_object)
|
||||
|
||||
def at_object_receive(self, moved_object, source_location, **kwargs):
|
||||
"""
|
||||
Hook called by Evennia as an object is moved here. We make sure it's added
|
||||
to the equipment handler.
|
||||
|
||||
Args:
|
||||
moved_object (Object): Object to move into this one (that is, into inventory).
|
||||
source_location (Object): Source location moved from.
|
||||
**kwargs: Passed from move operation; unused here.
|
||||
|
||||
"""
|
||||
self.equipment.add(moved_object)
|
||||
|
||||
def at_pre_object_leave(self, leaving_object, destination, **kwargs):
|
||||
"""
|
||||
Hook called when dropping an item. We don't allow to drop weilded/worn items
|
||||
(need to unwield/remove them first).
|
||||
|
||||
"""
|
||||
self.equipment.can_drop(leaving_object)
|
||||
|
||||
def at_object_leave(self, moved_object, destination, **kwargs):
|
||||
"""
|
||||
Called just before an object leaves from inside this object
|
||||
|
||||
Args:
|
||||
moved_obj (Object): The object leaving
|
||||
destination (Object): Where `moved_obj` is going.
|
||||
**kwargs (dict): Arbitrary, optional arguments for users
|
||||
overriding the call (unused by default).
|
||||
|
||||
"""
|
||||
self.equipment.remove(moved_object)
|
||||
|
||||
|
||||
class EvAdventureNPC(DefaultCharacter):
|
||||
"""
|
||||
This is the base class for all non-player entities, including monsters. These
|
||||
generally don't advance in level but uses a simplified, abstract measure of how
|
||||
dangerous or competent they are - the 'hit dice' (HD).
|
||||
|
||||
HD indicates how much health they have and how hard they hit. In _Knave_, HD also
|
||||
defaults to being the bonus for all abilities. HP is 4 x Hit die (this can then be
|
||||
customized per-entity of course).
|
||||
|
||||
Morale is set explicitly per-NPC, usually between 7 and 9.
|
||||
|
||||
Monsters don't use equipment in the way PCs do, instead their weapons and equipment
|
||||
are baked into their HD (and/or dropped as loot when they go down). If you want monsters
|
||||
or NPCs that can level and work the same as PCs, base them off the EvAdventureCharacter
|
||||
class instead.
|
||||
|
||||
Unlike for a Character, we generate all the abilities dynamically based on HD.
|
||||
|
||||
"""
|
||||
hit_dice = AttributeProperty(default=1)
|
||||
# note: this is the armor bonus, 10 lower than the armor defence (what is usually
|
||||
# referred to as ascending AC for many older D&D versions). So if AC is 14, this value
|
||||
# should be 4.
|
||||
armor = AttributeProperty(default=1)
|
||||
morale = AttributeProperty(default=9)
|
||||
hp = AttributeProperty(default=8)
|
||||
|
||||
@property
|
||||
def strength(self):
|
||||
return self.hit_dice
|
||||
|
||||
@property
|
||||
def dexterity(self):
|
||||
return self.hit_dice
|
||||
|
||||
@property
|
||||
def constitution(self):
|
||||
return self.hit_dice
|
||||
|
||||
@property
|
||||
def intelligence(self):
|
||||
return self.hit_dice
|
||||
|
||||
@property
|
||||
def wisdom(self):
|
||||
return self.hit_dice
|
||||
|
||||
@property
|
||||
def charisma(self):
|
||||
return self.hit_dice
|
||||
|
||||
@property
|
||||
def hp_max(self):
|
||||
return self.hit_dice * 4
|
||||
|
||||
def at_object_creation(self):
|
||||
"""
|
||||
Start with max health.
|
||||
|
||||
"""
|
||||
self.hp = self.hp_max
|
||||
|
||||
127
evennia/contrib/tutorials/evadventure/combat_turnbased.py
Normal file
127
evennia/contrib/tutorials/evadventure/combat_turnbased.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""
|
||||
EvAdventure turn-based combat
|
||||
|
||||
This implements a turn-based combat style, where both sides have a little longer time to
|
||||
choose their next action. If they don't react before a timer runs out, the previous action
|
||||
will be repeated. This means that a 'twitch' style combat can be created using the same
|
||||
mechanism, by just speeding up each 'turn'.
|
||||
|
||||
The combat is handled with a `Script` shared between all combatants; this tracks the state
|
||||
of combat and handles all timing elements.
|
||||
|
||||
Unlike in base _Knave_, the MUD version's combat is simultaneous; everyone plans and executes
|
||||
their turns simultaneously with minimum downtime. This version also includes a stricter
|
||||
handling of optimal distances than base _Knave_ (this would be handled by the GM normally).
|
||||
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from collections import defaultdict
|
||||
from evennia.scripts.scripts import DefaultScript
|
||||
from evennia.typeclasses.attributes import AttributeProperty
|
||||
from . import rules
|
||||
|
||||
|
||||
@dataclass
|
||||
class CombatantStats:
|
||||
"""
|
||||
Represents temporary combat-only data we need to track
|
||||
during combat for a single Character.
|
||||
"""
|
||||
weapon = None
|
||||
armor = None
|
||||
# abstract distance relationship to other combatants
|
||||
distance_matrix = {}
|
||||
# actions may affect what works better/worse next round
|
||||
advantage_actions_next_turn = []
|
||||
disadvantage_actions_next_turn = []
|
||||
|
||||
def get_distance(self, target):
|
||||
return self.distance_matrix.get(target)
|
||||
|
||||
def change_distance(self, target, change):
|
||||
current_dist = self.distance_matrix.get(target) # will raise error if None, as it should
|
||||
self.distance_matrix[target] = max(0, min(4, current_dist + target))
|
||||
|
||||
|
||||
class EvAdventureCombat(DefaultScript):
|
||||
"""
|
||||
This script is created when combat is initialized and stores a queue
|
||||
of all active participants. It's also possible to join (or leave) the fray later.
|
||||
|
||||
"""
|
||||
combatants = AttributeProperty(default=dict())
|
||||
queue = AttributeProperty(default=list())
|
||||
# turn counter - abstract time
|
||||
turn = AttributeProperty(default=1)
|
||||
# symmetric distance matrix
|
||||
distance_matrix = {}
|
||||
|
||||
def _refresh_distance_matrix(self):
|
||||
"""
|
||||
Refresh the distance matrix, either after movement or when a
|
||||
new combatant enters combat - everyone must have a symmetric
|
||||
distance to every other combatant (that is, if you are 'near' an opponent,
|
||||
they are also 'near' to you).
|
||||
|
||||
Distances are abstract and divided into four steps:
|
||||
|
||||
0. Close (melee, short weapons, fists, long weapons with disadvantage)
|
||||
1. Near (melee, long weapons, short weapons with disadvantage)
|
||||
2. Medium (thrown, ranged with disadvantage)
|
||||
3. Far (ranged, thrown with disadvantage)
|
||||
4. Disengaging/fleeing (no weapons can be used)
|
||||
|
||||
Distance is tracked to each opponent individually. One can move 1 step and atack
|
||||
or 3 steps without attacking. Ranged weapons can't be used in range 0, 1 and
|
||||
melee weapons can't be used at ranges 2, 3.
|
||||
|
||||
New combatants will start at a distance averaged between the optimal ranges
|
||||
of them and their opponents.
|
||||
|
||||
"""
|
||||
handled = []
|
||||
for combatant1, combatant_stats1 in self.combatants.items():
|
||||
for combatant2, combatant_stats2 in self.combatants.items():
|
||||
|
||||
if combatant1 == combatant2:
|
||||
continue
|
||||
|
||||
# only update if data was not available already (or drifted
|
||||
# out of sync, which should not happen)
|
||||
dist1 = combatant_stats1.get_distance(combatant2)
|
||||
dist2 = combatant_stats2.get_distance(combatant1)
|
||||
if None in (dist1, dist2) or dist1 != dist2:
|
||||
avg_range = round(0.5 * (combatant1.weapon.range_optimal
|
||||
+ combatant2.weapon.range_optimal))
|
||||
combatant_stats1.distance_matrix[combatant2] = avg_range
|
||||
combatant_stats2.distance_matrix[combatant1] = avg_range
|
||||
|
||||
handled.append(combatant1)
|
||||
handled.append(combatant2)
|
||||
|
||||
self.combatants = handled
|
||||
|
||||
def _move_relative_to(self, combatant, target_combatant, change):
|
||||
"""
|
||||
Change the distance to a target.
|
||||
|
||||
Args:
|
||||
combatant (Character): The one doing the change.
|
||||
target_combatant (Character): The one changing towards.
|
||||
change (int): A +/- change value. Result is always in range 0..4.
|
||||
|
||||
"""
|
||||
self.combatants[combatant].change_distance(target_combatant, change)
|
||||
self.combatants[target_combatant].change_distance(combatant, change)
|
||||
|
||||
def add_combatant(self, combatant):
|
||||
self.combatants[combatant] = CombatantStats(
|
||||
weapon=combatant.equipment.get("weapon"),
|
||||
armor=combatant.equipment.armor,
|
||||
)
|
||||
self._refresh_distance_matrix()
|
||||
|
||||
def remove_combatant(self, combatant):
|
||||
self.combatants.pop(combatant, None)
|
||||
self._refresh_distance_matrix()
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
"""
|
||||
All items in the game inherit from a base object. The properties (what you can do
|
||||
with an object, such as wear, wield, eat, drink, kill etc) are all controlled by
|
||||
Tags.
|
||||
|
||||
"""
|
||||
|
||||
from evennia.objects.objects import DefaultObject
|
||||
from evennia.typeclasses.attributes import AttributeProperty
|
||||
|
||||
|
||||
class EvAdventureObject(DefaultObject):
|
||||
"""
|
||||
Base in-game entity.
|
||||
|
||||
"""
|
||||
# inventory management
|
||||
wield_slot = AttributeProperty(default=None)
|
||||
wear_slot = AttributeProperty(default=None)
|
||||
inventory_slot_usage = AttributeProperty(default=1)
|
||||
armor = AttributeProperty(default=0)
|
||||
# when 0, item is destroyed and is unusable
|
||||
quality = AttributeProperty(default=1)
|
||||
|
||||
|
||||
class EvAdventureObjectFiller(EvAdventureObject):
|
||||
"""
|
||||
In _Knave_, the inventory slots act as an extra measure of how you are affected by
|
||||
various averse effects. For example, mud or water could fill up some of your inventory
|
||||
slots and make the equipment there unusable until you cleaned it. Inventory is also
|
||||
used to track how long you can stay under water etc - the fewer empty slots you have,
|
||||
the less time you can stay under water due to carrying so much stuff with you.
|
||||
|
||||
This class represents such an effect filling up an empty slot. It has a quality of 0,
|
||||
meaning it's unusable.
|
||||
|
||||
"""
|
||||
quality = AttributeProperty(default=0)
|
||||
|
||||
|
||||
class EvAdventureWeapon(EvAdventureObject):
|
||||
"""
|
||||
Base weapon class for all EvAdventure weapons.
|
||||
|
||||
"""
|
||||
wield_slot = AttributeProperty(default="weapon")
|
||||
damage_roll = AttributeProperty(default="1d6")
|
||||
# at which ranges this weapon can be used. If not listed, unable to use
|
||||
range_optimal = AttributeProperty(default=0) # normal usage
|
||||
range_suboptimal = AttributeProperty(default=1) # usage with disadvantage
|
||||
|
||||
|
||||
class EvAdventureRunestone(EvAdventureWeapon):
|
||||
"""
|
||||
Base class for magic runestones. In _Knave_, every spell is represented by a rune stone
|
||||
that takes up an inventory slot. It is wielded as a weapon in order to create the specific
|
||||
magical effect provided by the stone. Normally each stone can only be used once per day but
|
||||
they are quite powerful (and scales with caster level).
|
||||
|
||||
"""
|
||||
|
|
@ -352,3 +352,17 @@ character_generation = {
|
|||
],
|
||||
}
|
||||
|
||||
|
||||
reactions = [
|
||||
('2', "Hostile"),
|
||||
('3-5', "Unfriendly"),
|
||||
('6-8', "Unsure"),
|
||||
('9-11', "Talkative"),
|
||||
('12', "Helpful"),
|
||||
]
|
||||
|
||||
initiative = [
|
||||
('1-3', "Enemy acts first"),
|
||||
('4-6', "PC acts first"),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,142 +1,292 @@
|
|||
"""
|
||||
MUD ruleset based on the _Knave_ OSR tabletop RPG by Ben Milton (modified for MUD use).
|
||||
|
||||
The rules are divided into three parts:
|
||||
The rules are divided into a set of classes. While each class (except chargen) could
|
||||
also have been stand-alone functions, having them as classes makes it a little easier
|
||||
to use them as the base for your own variation (tweaking values etc).
|
||||
|
||||
- Character generation - these are rules only used when creating a character.
|
||||
- Improvement - these are rules used with experience to improve the character
|
||||
over time.
|
||||
- Actions - all in-game interactions (making use of the character's abilities)
|
||||
are defined as discreet _actions_ in the game. An action is the smallest rule
|
||||
unit to accomplish something with rule support. While in a tabletop game you
|
||||
have a human game master to arbitrate, the computer requires exactness. While
|
||||
free-form roleplay is also possible, only the actions defined here will have a
|
||||
coded support.
|
||||
- Roll-engine: Class with methods for making all dice rolls needed by the rules. Knave only
|
||||
has a few resolution rolls, but we define helper methods for different actions the
|
||||
character will be able to do in-code.
|
||||
- Character generation - this is a container used for holding, tweaking and setting
|
||||
all character data during character generation. At the end it will save itself
|
||||
onto the Character for permanent storage.
|
||||
- Improvement - this container holds rules used with experience to improve the
|
||||
character over time.
|
||||
- Charsheet - a container with tools for visually displaying the character sheet in-game.
|
||||
|
||||
This module presents several singletons to import
|
||||
|
||||
- `dice` - the `EvAdventureRollEngine` for all random resolution and table-rolling.
|
||||
- `character_sheet` - the `EvAdventureCharacterSheet` visualizer.
|
||||
- `improvement` - the EvAdventureImprovement` class for handling char xp and leveling.
|
||||
|
||||
"""
|
||||
from random import randint
|
||||
from dataclasses import dataclass
|
||||
from evennia.utils.evform import EvForm
|
||||
from evennia.utils.evtable import EvTable
|
||||
from .utils import roll
|
||||
from .random_tables import character_generation as chargen_table
|
||||
|
||||
|
||||
# Basic rolls
|
||||
|
||||
def saving_throw(bonus, advantage=False, disadvantage=False):
|
||||
class EvAdventureRollEngine:
|
||||
"""
|
||||
To save, roll d20 + relevant Attrribute bonus > 15 (always 15).
|
||||
|
||||
Args:
|
||||
advantage (bool): Roll 2d20 and use the bigger number.
|
||||
disadvantage (bool): Roll 2d20 and use the smaller number.
|
||||
|
||||
Returns:
|
||||
bool: If the save was passed or not.
|
||||
|
||||
Notes:
|
||||
Advantage and disadvantage cancel each other out.
|
||||
|
||||
Example:
|
||||
Trying to overcome the effects of poison, roll d20 + Constitution-bonus above 15.
|
||||
This groups all dice rolls of EvAdventure. These could all have been normal functions, but we
|
||||
are group them in a class to make them easier to partially override and replace later.
|
||||
|
||||
"""
|
||||
if not (advantage or disadvantage) or (advantage and disadvantage):
|
||||
# normal roll
|
||||
dice_roll = roll("1d20")
|
||||
elif advantage:
|
||||
dice_roll = max(roll("1d20"), roll("1d20"))
|
||||
else:
|
||||
dice_roll = min(roll("1d20"), roll("1d20"))
|
||||
return (dice_roll + bonus) > 15
|
||||
|
||||
@staticmethod
|
||||
def roll(roll_string, max_number=10):
|
||||
"""
|
||||
NOTE: In evennia/contribs/rpg/dice/ is a more powerful dice roller with
|
||||
more features, such as modifiers, secret rolls etc. This is much simpler and only
|
||||
gets a simple sum of normal rpg-dice.
|
||||
|
||||
def roll_attribute_bonus():
|
||||
"""
|
||||
For the MUD version, we use a flat bonus and let the user redistribute it. This
|
||||
function (unused by default) implements the original Knave random generator for
|
||||
the Attribute bonus, if you prefer producing more 'unbalanced' characters.
|
||||
Args:
|
||||
roll_string (str): A roll using standard rpg syntax, <number>d<diesize>, like
|
||||
1d6, 2d10 etc. Max die-size is 1000.
|
||||
max_number (int): The max number of dice to roll. Defaults to 10, which is usually
|
||||
more than enough.
|
||||
|
||||
The Attribute bonus is generated by rolling the lowest value of 3d6.
|
||||
Returns:
|
||||
int: The rolled result - sum of all dice rolled.
|
||||
|
||||
Returns:
|
||||
int: The randomly generated Attribute bonus.
|
||||
Raises:
|
||||
TypeError: If roll_string is not on the right format or otherwise doesn't validate.
|
||||
|
||||
"""
|
||||
return min(roll("1d6"), roll("1d6"), roll("1d6"))
|
||||
Notes:
|
||||
Since we may see user input to this function, we make sure to validate the inputs (we
|
||||
wouldn't bother much with that if it was just for developer use).
|
||||
|
||||
"""
|
||||
max_diesize = 1000
|
||||
roll_string = roll_string.lower()
|
||||
if 'd' not in roll_string:
|
||||
raise TypeError(f"Dice roll '{roll_string}' was not recognized. "
|
||||
"Must be `<number>d<dicesize>`.")
|
||||
number, diesize = roll_string.split('d', 1)
|
||||
try:
|
||||
number = int(number)
|
||||
diesize = int(diesize)
|
||||
except Exception:
|
||||
raise TypeError(f"The number and dice-size of '{roll_string}' must be numerical.")
|
||||
if 0 < number > max_number:
|
||||
raise TypeError(f"Invalid number of dice rolled (must be between 1 and {max_number})")
|
||||
if 0 < diesize > max_diesize:
|
||||
raise TypeError(f"Invalid die-size used (must be between 1 and {max_diesize} sides)")
|
||||
|
||||
def roll_random_table(dieroll, table, table_choices):
|
||||
"""
|
||||
Make a roll on a random table.
|
||||
# At this point we know we have valid input - roll and all dice together
|
||||
return sum(randint(1, diesize) for _ in range(number))
|
||||
|
||||
Args:
|
||||
dieroll (str): The dice to roll, like 1d6, 1d20, 3d6 etc).
|
||||
table_choices (iterable): If a list of single elements, the die roll
|
||||
should fully encompass the table, like a 1d20 roll for a table
|
||||
with 20 elements. If each element is a tuple, the first element
|
||||
of the tuple is assumed to be a string 'X-Y' indicating the
|
||||
range of values that should match the roll.
|
||||
@staticmethod
|
||||
def roll_with_advantage_or_disadvantage(advantage=False, disadvantage=False):
|
||||
"""
|
||||
Base roll of d20, or 2d20, based on dis/advantage given.
|
||||
|
||||
Returns:
|
||||
Any: The result of the random roll.
|
||||
Args:
|
||||
bonus (int): The ability bonus to apply, like strength or charisma.
|
||||
advantage (bool): Roll 2d20 and use the bigger number.
|
||||
disadvantage (bool): Roll 2d20 and use the smaller number.
|
||||
|
||||
Example:
|
||||
`roll table_choices = [('1-5', "Blue"), ('6-9': "Red"), ('10', "Purple")]`
|
||||
Notes:
|
||||
Disadvantage and advantage cancel each other out.
|
||||
|
||||
Notes:
|
||||
If the roll is outside of the listing, the closest edge value is used.
|
||||
|
||||
"""
|
||||
roll_result = roll(dieroll)
|
||||
|
||||
if isinstance(table_choices[0], (tuple, list)):
|
||||
# tuple with range conditional, like ('1-5', "Blue") or ('10', "Purple")
|
||||
max_range = -1
|
||||
min_range = 10**6
|
||||
for (valrange, choice) in table_choices:
|
||||
|
||||
minval, *maxval = valrange.split('-', 1)
|
||||
minval = abs(int(minval))
|
||||
maxval = abs(int(maxval[0]) if maxval else minval)
|
||||
|
||||
# we store the largest/smallest values so far in case we need to use them
|
||||
max_range = max(max_range, maxval)
|
||||
min_range = min(min_range, minval)
|
||||
|
||||
if minval <= roll_result <= maxval:
|
||||
return choice
|
||||
|
||||
# if we have no result, we are outside of the range, we pick the edge values. It is also
|
||||
# possible the range contains 'gaps', but that'd be an error in the random table itself.
|
||||
if roll_result > max_range:
|
||||
return max_range
|
||||
"""
|
||||
if not (advantage or disadvantage) or (advantage and disadvantage):
|
||||
# normal roll
|
||||
return roll("1d20")
|
||||
elif advantage:
|
||||
return max(roll("1d20"), roll("1d20"))
|
||||
else:
|
||||
return min_range
|
||||
else:
|
||||
# regular list - one line per value.
|
||||
roll_result = max(1, min(len(table_choices), roll_result))
|
||||
return table_choices[roll_result - 1]
|
||||
return min(roll("1d20"), roll("1d20"))
|
||||
|
||||
@staticmethod
|
||||
def saving_throw(character, bonus_type='strength',
|
||||
advantage=False, disadvantage=False, modifier=0):
|
||||
"""
|
||||
A saving throw without a clear enemy to beat. In _Knave_ all unopposed saving
|
||||
throws always tries to beat 15, so (d20 + bonus + modifier) > 15.
|
||||
|
||||
Args:
|
||||
character (Object): The one attempting to save themselves.
|
||||
bonus (str): The ability bonus to apply, like strength or charisma. Minimum is 1.
|
||||
advantage (bool): Roll 2d20 and use the bigger number.
|
||||
disadvantage (bool): Roll 2d20 and use the smaller number.
|
||||
modifier (int): An additional +/- modifier to the roll.
|
||||
|
||||
Returns:
|
||||
tuple: (bool, str): If the save was passed or not. The second element is the
|
||||
quality of the roll - None (normal), "critical fail" and "critical success".
|
||||
|
||||
Notes:
|
||||
Advantage and disadvantage cancel each other out.
|
||||
|
||||
Example:
|
||||
Trying to overcome the effects of poison, roll d20 + Constitution-bonus above 15.
|
||||
|
||||
"""
|
||||
bonus = getattr(character, bonus_type, 1)
|
||||
dice_roll = roll_with_advantage_or_disadvantage(advantage, disadvantage)
|
||||
if dice_roll == 1:
|
||||
quality = "critical failure"
|
||||
elif dice_roll == 20:
|
||||
quality = "critical success"
|
||||
else:
|
||||
quality = None
|
||||
return (dice_roll + bonus + modifier) > 15, quality
|
||||
|
||||
@staticmethod
|
||||
def opposed_saving_throw(attacker, defender, attack_type='strength', defense_type='armor',
|
||||
advantage=False, disadvantage=False):
|
||||
"""
|
||||
An saving throw that tries to beat an active opposing side.
|
||||
|
||||
Args:
|
||||
attacker (Character): The attacking party.
|
||||
defender (Character): The one defending.
|
||||
attack_type (str): Which ability to use in the attack, like 'strength' or 'willpower'.
|
||||
Minimum is always 1.
|
||||
defense_type (str): Which ability to defend with, in addition to 'armor'.
|
||||
Minimum is always 11 (bonus + 10 is always the defense in _Knave_).
|
||||
advantage (bool): Roll 2d20 and use the bigger number.
|
||||
disadvantage (bool): Roll 2d20 and use the smaller number.
|
||||
modifier (int): An additional +/- modifier to the roll.
|
||||
|
||||
Returns:
|
||||
tuple: (bool, str): If the attack succeed or not. The second element is the
|
||||
quality of the roll - None (normal), "critical fail" and "critical success".
|
||||
Notes:
|
||||
Advantage and disadvantage cancel each other out.
|
||||
|
||||
"""
|
||||
attack_bonus = getattr(attacker, attack_type, 1)
|
||||
# defense is always bonus + 10 in Knave
|
||||
defender_defense = getattr(defender, defense_type_type, 1) + 10
|
||||
dice_roll = roll_with_advantage_or_disadvantage(advantage, disadvantage)
|
||||
if dice_roll == 1:
|
||||
quality = "critical failure"
|
||||
elif dice_roll == 20:
|
||||
quality = "critical success"
|
||||
else:
|
||||
quality = None
|
||||
return (dice_roll + attack_bonus + modifier) > defender_defense, quality
|
||||
|
||||
# specific rolls / actions
|
||||
|
||||
@staticmethod
|
||||
def melee_attack(attacker, defender, advantage=False, disadvantage=False):
|
||||
"""Close attack (strength vs armor)"""
|
||||
return opposed_saving_throw(
|
||||
attacker, defender, attack_type="strength", defense_type="armor",
|
||||
advantage=advantage, disadvantage=disadvantage)
|
||||
|
||||
@staticmethod
|
||||
def ranged_attack(attacker, defender, advantage=False, disadvantage=False):
|
||||
"""Ranged attack (wisdom vs armor)"""
|
||||
return opposed_saving_throw(
|
||||
attacker, defender, attack_type="wisdom", defense_type="armor",
|
||||
advantage=advantage, disadvantage=disadvantage)
|
||||
|
||||
@staticmethod
|
||||
def magic_attack(attacker, defender, advantage=False, disadvantage=False):
|
||||
"""Magic attack (int vs dexterity)"""
|
||||
return opposed_saving_throw(
|
||||
attacker, defender, attack_type="intelligence", defense_type="dexterity",
|
||||
advantage=advantage, disadvantage=disadvantage)
|
||||
|
||||
@staticmethod
|
||||
def morale_check(defender):
|
||||
"""
|
||||
A morale check is done for NPCs/monsters. It's done with a 2d6 against
|
||||
their morale.
|
||||
|
||||
Args:
|
||||
defender (NPC): The entity trying to defend its morale.
|
||||
|
||||
Returns:
|
||||
bool: False if morale roll failed, True otherwise.
|
||||
|
||||
"""
|
||||
return roll('2d6') <= defender.morale
|
||||
|
||||
@staticmethod
|
||||
def healing_from_rest(character):
|
||||
"""
|
||||
A meal and a full night's rest allow for regaining 1d8 + Const bonus HP.
|
||||
|
||||
Args:
|
||||
character (Character): The one resting.
|
||||
|
||||
Returns:
|
||||
int: How much HP was healed. This is never more than how damaged we are.
|
||||
|
||||
"""
|
||||
# we can't heal more than our damage
|
||||
damage = character.hp_max - character.hp
|
||||
healed = roll('1d8') + character.constitution
|
||||
return min(damage, healed)
|
||||
|
||||
@staticmethod
|
||||
def roll_random_table(dieroll, table, table_choices):
|
||||
"""
|
||||
Make a roll on a random table.
|
||||
|
||||
Args:
|
||||
dieroll (str): The dice to roll, like 1d6, 1d20, 3d6 etc).
|
||||
table_choices (iterable): If a list of single elements, the die roll
|
||||
should fully encompass the table, like a 1d20 roll for a table
|
||||
with 20 elements. If each element is a tuple, the first element
|
||||
of the tuple is assumed to be a string 'X-Y' indicating the
|
||||
range of values that should match the roll.
|
||||
|
||||
Returns:
|
||||
Any: The result of the random roll.
|
||||
|
||||
Example:
|
||||
`roll table_choices = [('1-5', "Blue"), ('6-9': "Red"), ('10', "Purple")]`
|
||||
|
||||
Notes:
|
||||
If the roll is outside of the listing, the closest edge value is used.
|
||||
|
||||
"""
|
||||
roll_result = roll(dieroll)
|
||||
|
||||
if isinstance(table_choices[0], (tuple, list)):
|
||||
# tuple with range conditional, like ('1-5', "Blue") or ('10', "Purple")
|
||||
max_range = -1
|
||||
min_range = 10**6
|
||||
for (valrange, choice) in table_choices:
|
||||
|
||||
minval, *maxval = valrange.split('-', 1)
|
||||
minval = abs(int(minval))
|
||||
maxval = abs(int(maxval[0]) if maxval else minval)
|
||||
|
||||
# we store the largest/smallest values so far in case we need to use them
|
||||
max_range = max(max_range, maxval)
|
||||
min_range = min(min_range, minval)
|
||||
|
||||
if minval <= roll_result <= maxval:
|
||||
return choice
|
||||
|
||||
# if we have no result, we are outside of the range, we pick the edge values. It is also
|
||||
# possible the range contains 'gaps', but that'd be an error in the random table itself.
|
||||
if roll_result > max_range:
|
||||
return max_range
|
||||
else:
|
||||
return min_range
|
||||
else:
|
||||
# regular list - one line per value.
|
||||
roll_result = max(1, min(len(table_choices), roll_result))
|
||||
return table_choices[roll_result - 1]
|
||||
|
||||
|
||||
# character generation
|
||||
|
||||
@dataclass
|
||||
class CharAttribute:
|
||||
"""
|
||||
A character Attribute, like strength or wisdom, has a _bonus_, used
|
||||
to improve the result of doing a related action. It also has a _defense_ value
|
||||
which is always 10 points higher than the bonus. For example, to attack
|
||||
someone, you'd have to roll d20 + `strength bonus` to beat the `strength defense`
|
||||
of the enemy.
|
||||
|
||||
"""
|
||||
bonus: str = 0
|
||||
|
||||
@property
|
||||
def defense(self):
|
||||
return bonus + 10
|
||||
|
||||
|
||||
class CharacterGeneration:
|
||||
class EvAdventureCharacterGeneration:
|
||||
"""
|
||||
This collects all the rules for generating a new character. An instance of this class can be
|
||||
used to track all the stats during generation and will be used to apply all the data to the
|
||||
|
|
@ -170,56 +320,74 @@ class CharacterGeneration:
|
|||
Initialize starting values
|
||||
|
||||
"""
|
||||
# for clarity we initialize the engine here rather than use the
|
||||
# global singleton at the end of the module
|
||||
dice = EvAdventureRollEngine()
|
||||
|
||||
# name will likely be modified later
|
||||
self.name = roll_random_table('1d282', chargen_table['name'])
|
||||
self.name = dice.roll_random_table('1d282', chargen_table['name'])
|
||||
|
||||
# base attribute bonuses
|
||||
self.strength = CharAttribute(bonus=2)
|
||||
self.dexterity = CharAttribute(bonus=2)
|
||||
self.constitution = CharAttribute(bonus=2)
|
||||
self.intelligence = CharAttribute(bonus=2)
|
||||
self.wisdom = CharAttribute(bonus=2)
|
||||
self.charisma = CharAttribute(bonus=2)
|
||||
self.strength = 2
|
||||
self.dexterity = 2
|
||||
self.constitution = 2
|
||||
self.intelligence = 2
|
||||
self.wisdom = 2
|
||||
self.charisma = 2
|
||||
|
||||
self.armor = CharAttribute(bonus=1) # un-armored default
|
||||
self.armor_bonus = 1 # un-armored default
|
||||
|
||||
# physical attributes (only for rp purposes)
|
||||
self.physique = roll_random_table('1d20', chargen_table['physique'])
|
||||
self.face = roll_random_table(chargen_table['1d20', 'face'])
|
||||
self.skin = roll_random_table(chargen_table['1d20', 'skin'])
|
||||
self.hair = roll_random_table(chargen_table['1d20', 'hair'])
|
||||
self.clothing = roll_random_table(chargen_table['1d20', 'clothing'])
|
||||
self.virtue = roll_random_table(chargen_table['1d20', 'virtue'])
|
||||
self.vice = roll_random_table(chargen_table['1d20', 'vice'])
|
||||
self.background = roll_random_table(chargen_table['1d20', 'background'])
|
||||
self.misfortune = roll_random_table(chargen_table['1d20', 'misfortune'])
|
||||
self.alignment = roll_random_table(chargen_table['1d20', 'alignment'])
|
||||
self.physique = dice.roll_random_table('1d20', chargen_table['physique'])
|
||||
self.face = dice.roll_random_table('1d20', chargen_table['face'])
|
||||
self.skin = dice.roll_random_table('1d20', chargen_table['skin'])
|
||||
self.hair = dice.roll_random_table('1d20', chargen_table['hair'])
|
||||
self.clothing = dice.roll_random_table('1d20', chargen_table['clothing'])
|
||||
self.speech = dice.roll_random_table('1d20', chargen_table['speech'])
|
||||
self.virtue = dice.roll_random_table('1d20', chargen_table['virtue'])
|
||||
self.vice = dice.roll_random_table('1d20', chargen_table['vice'])
|
||||
self.background = dice.roll_random_table('1d20', chargen_table['background'])
|
||||
self.misfortune = dice.roll_random_table('1d20', chargen_table['misfortune'])
|
||||
self.alignment = dice.roll_random_table('1d20', chargen_table['alignment'])
|
||||
|
||||
# same for all
|
||||
self.exploration_speed = 120
|
||||
self.combat_speed = 40
|
||||
self.hp = 0
|
||||
self.hp_max = 8
|
||||
self.hp = self.hp_max
|
||||
self.xp = 0
|
||||
self.level = 1
|
||||
|
||||
# random equipment
|
||||
self.armor = roll_random_table('1d20', chargen_table['armor'])
|
||||
self.armor = dice.roll_random_table('1d20', chargen_table['armor'])
|
||||
|
||||
_helmet_and_shield = roll_random_table('1d20', chargen_table["helmets and shields"])
|
||||
_helmet_and_shield = dice.roll_random_table('1d20', chargen_table["helmets and shields"])
|
||||
self.helmet = "helmet" if "helmet" in _helmet_and_shield else "none"
|
||||
self.shield = "shield" if "shield" in _helmet_and_shield else "none"
|
||||
|
||||
self.weapon = roll_random_table(chargen_table['1d20', "starting_weapon"])
|
||||
self.weapon = dice.roll_random_table(chargen_table['1d20', "starting_weapon"])
|
||||
|
||||
self.equipment = [
|
||||
"ration",
|
||||
"ration",
|
||||
roll_random_table(chargen_table['1d20', "dungeoning gear"]),
|
||||
roll_random_table(chargen_table['1d20', "dungeoning gear"]),
|
||||
roll_random_table(chargen_table['1d20', "general gear 1"]),
|
||||
roll_random_table(chargen_table['1d20', "general gear 2"]),
|
||||
dice.roll_random_table(chargen_table['1d20', "dungeoning gear"]),
|
||||
dice.roll_random_table(chargen_table['1d20', "dungeoning gear"]),
|
||||
dice.roll_random_table(chargen_table['1d20', "general gear 1"]),
|
||||
dice.roll_random_table(chargen_table['1d20', "general gear 2"]),
|
||||
]
|
||||
|
||||
def build_desc(self):
|
||||
"""
|
||||
Generate a backstory / description paragraph from random elements.
|
||||
|
||||
"""
|
||||
return (
|
||||
f"{self.background.title()}. Wears {self.clothing} clothes, and has {self.speech} "
|
||||
f"speech. Has a {self.physique} physique, a {self.face} face, {self.skin} skin and "
|
||||
f"{self.hair} hair. Is {self.virtue}, but {self.vice}. Has been {self.misfortune} in "
|
||||
f"the past. Favors {self.alignment}."
|
||||
)
|
||||
|
||||
def adjust_attribute(self, source_attribute, target_attribute, value):
|
||||
"""
|
||||
Redistribute bonus from one attribute to another. The resulting values
|
||||
|
|
@ -257,3 +425,175 @@ class CharacterGeneration:
|
|||
permanently.
|
||||
|
||||
"""
|
||||
character.key = self.name
|
||||
character.strength = self.strength
|
||||
character.dexterity = self.dexterity
|
||||
character.constitution = self.constitution
|
||||
character.intelligence = self.intelligence
|
||||
character.wisdom = self.wisdom
|
||||
character.charisma = self.charisma
|
||||
|
||||
character.armor = self.armor_bonus
|
||||
# character.exploration_speed = self.exploration_speed
|
||||
# character.combat_speed = self.combat_speed
|
||||
|
||||
character.hp = self.hp
|
||||
character.level = self.level
|
||||
character.xp = self.xp
|
||||
|
||||
character.db.desc = self.build_desc()
|
||||
|
||||
if self.weapon:
|
||||
character.equipment.add(self.weapon)
|
||||
character.equipment.wield(self.weapon)
|
||||
if self.shield:
|
||||
character.equipment.add(self.shield)
|
||||
character.equipment.wield(self.shield)
|
||||
if self.armor:
|
||||
character.equipment.add(self.armor)
|
||||
character.equipment.wear(self.armor)
|
||||
if self.helmet:
|
||||
character.equipment.add(self.helmet)
|
||||
character.equipment.wear(self.helmet)
|
||||
|
||||
|
||||
# character improvement
|
||||
|
||||
class EvAdventureImprovement:
|
||||
"""
|
||||
Handle XP gains and level upgrades. Grouped in a class in order to
|
||||
make it easier to override the mechanism.
|
||||
|
||||
"""
|
||||
xp_per_level = 1000
|
||||
amount_of_abilities_to_upgrade = 3
|
||||
max_ability_bonus = 10 # bonus +10, defense 20
|
||||
|
||||
@staticmethod
|
||||
def add_xp(character, xp):
|
||||
"""
|
||||
Add new XP.
|
||||
|
||||
Args:
|
||||
character (Character): The character to improve.
|
||||
xp (int): The amount of gained XP.
|
||||
|
||||
Returns:
|
||||
bool: If a new level was reached or not.
|
||||
|
||||
Notes:
|
||||
level 1 -> 2 = 1000 XP
|
||||
level 2 -> 3 = 2000 XP etc
|
||||
|
||||
"""
|
||||
character.xp += xp
|
||||
next_level_xp = character.level * xp_per_level
|
||||
return character.xp >= next_level_xp
|
||||
|
||||
@staticmethod
|
||||
def level_up(character, *abilities):
|
||||
"""
|
||||
Perform the level-up action.
|
||||
|
||||
Args:
|
||||
character (Character): The entity to level-up.
|
||||
*abilities (str): A set of abilities (like 'strength', 'dexterity' (normally 3)
|
||||
to upgrade by 1. Max is usually +10.
|
||||
Notes:
|
||||
We block increases above a certain value, but we don't raise an error here, that
|
||||
will need to be done earlier, when the user selects the ability to increase.
|
||||
|
||||
"""
|
||||
dice = EvAdventureRollEngine()
|
||||
|
||||
character.level += 1
|
||||
for ability in set(abilities[:amount_of_abilities_to_upgrades]):
|
||||
# limit to max amount allowed, each one unique
|
||||
try:
|
||||
# set at most to the max bonus
|
||||
current_bonus = getattr(character, ability)
|
||||
setattr(character, ability, min(max_ability_bonus, current_bonus + 1))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
new_hp_max = max(character.max_hpdice.roll(f"{character.level}d8"))
|
||||
|
||||
|
||||
# character sheet visualization
|
||||
|
||||
class EvAdventureCharacterSheet:
|
||||
"""
|
||||
Generate a character sheet. This is grouped in a class in order to make
|
||||
it easier to override the look of the sheet.
|
||||
|
||||
"""
|
||||
|
||||
sheet = """
|
||||
+----------------------------------------------------------------------------+
|
||||
| Name: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |
|
||||
+----------------------------------------------------------------------------+
|
||||
| STR: x2xxxxx DEX: x3xxxxx CON: x4xxxxx WIS: x5xxxxx CHA: x6xxxxx |
|
||||
+----------------------------------------------------------------------------+
|
||||
| HP: x7xxxxx XP: x8xxxxx Exploration speed: x9x Combat speed: xAx |
|
||||
+----------------------------------------------------------------------------+
|
||||
| Desc: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |
|
||||
| xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxBxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |
|
||||
| xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |
|
||||
+----------------------------------------------------------------------------+
|
||||
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
|
||||
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
|
||||
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
|
||||
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
|
||||
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
|
||||
| cccccccccccccccccccccccccccccccccc1ccccccccccccccccccccccccccccccccccccccc |
|
||||
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
|
||||
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
|
||||
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
|
||||
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
|
||||
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
|
||||
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
|
||||
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
|
||||
+----------------------------------------------------------------------------+
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get(character):
|
||||
"""
|
||||
Generate a character sheet from the character's stats.
|
||||
|
||||
"""
|
||||
equipment = character.equipment.wielded + character.equipment.worn + character.carried
|
||||
# divide into chunks of max 10 length (to go into two columns)
|
||||
equipment_table = EvTable(
|
||||
table=[equipment[i: i + 10] for i in range(0, len(equipment), 10)]
|
||||
)
|
||||
form = EvForm({"FORMCHAR": 'x', "TABLECHAR": 'c', "SHEET": sheet})
|
||||
form.map(
|
||||
cells={
|
||||
1: character.key,
|
||||
2: f"+{character.strength}({character.strength + 10})",
|
||||
3: f"+{character.dexterity}({character.dexterity + 10})",
|
||||
4: f"+{character.constitution}({character.constitution + 10})",
|
||||
5: f"+{character.wisdom}({character.wisdom + 10})",
|
||||
6: f"+{character.charisma}({character.charisma + 10})",
|
||||
7: f"{character.hp}/{character.hp_max}",
|
||||
8: character.xp,
|
||||
9: character.exploration_speed,
|
||||
'A': character.combat_speed,
|
||||
'B': character.db.desc,
|
||||
},
|
||||
tables={
|
||||
1: equipment_table,
|
||||
}
|
||||
)
|
||||
return str(form)
|
||||
|
||||
|
||||
# singletons
|
||||
|
||||
# access sheet as rules.character_sheet.get(character)
|
||||
character_sheet = CharacterSheet()
|
||||
# access rolls e.g. with rules.dice.opposed_saving_throw(...)
|
||||
dice = EvAdventureRollEngine()
|
||||
# access improvement e.g. with rules.improvement.add_xp(character, xp)
|
||||
improvement = EvAdventureImprovement()
|
||||
|
|
|
|||
26
evennia/contrib/tutorials/evadventure/tests.py
Normal file
26
evennia/contrib/tutorials/evadventure/tests.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""
|
||||
Tests for EvAdventure.
|
||||
|
||||
"""
|
||||
|
||||
from evennia.utils import create
|
||||
from evennia.utils.test_resources import BaseEvenniaTest
|
||||
from .character import EvAdventureCharacter
|
||||
from .objects import EvAdventureObject
|
||||
|
||||
class EvAdventureMixin:
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.character = create.create_object(EvAdventureCharacter, key="testchar")
|
||||
self.helmet = create.create_object(EvAdventureObject, key="helmet",
|
||||
attributes=[("wear_slot", "helmet")])
|
||||
self.armor = create.create_object(EvAdventureObject, key="armor",
|
||||
attributes=[("wear_slot", "armor")])
|
||||
self.weapon = create.create_object(EvAdventureObject, key="weapon",
|
||||
attributes=[("wield_slot", "weapon")])
|
||||
self.shield = create.create_object(EvAdventureObject, key="shield",
|
||||
attributes=[("wield_slot", "shield")])
|
||||
|
||||
class EvAdventureEquipmentTest(EvAdventureMixin, BaseEvenniaTest):
|
||||
pass
|
||||
|
||||
|
|
@ -2,46 +2,5 @@
|
|||
Various utilities.
|
||||
|
||||
"""
|
||||
from random import randint
|
||||
|
||||
|
||||
def roll(roll_string, max_number=10):
|
||||
"""
|
||||
NOTE: In evennia/contribs/rpg/dice/ is a more powerful dice roller with
|
||||
more features, such as modifiers, secret rolls etc. This is much simpler and only
|
||||
gets a simple sum of normal rpg-dice.
|
||||
|
||||
Args:
|
||||
roll_string (str): A roll using standard rpg syntax, <number>d<diesize>, like
|
||||
1d6, 2d10 etc. Max die-size is 1000.
|
||||
max_number (int): The max number of dice to roll. Defaults to 10, which is usually
|
||||
more than enough.
|
||||
|
||||
Returns:
|
||||
int: The rolled result - sum of all dice rolled.
|
||||
|
||||
Raises:
|
||||
TypeError: If roll_string is not on the right format or otherwise doesn't validate.
|
||||
|
||||
Notes:
|
||||
Since we may see user input to this function, we make sure to validate the inputs (we
|
||||
wouldn't bother much with that if it was just for developer use).
|
||||
|
||||
"""
|
||||
max_diesize = 1000
|
||||
roll_string = roll_string.lower()
|
||||
if 'd' not in roll_string:
|
||||
raise TypeError(f"Dice roll '{roll_string}' was not recognized. Must be `<number>d<dicesize>`.")
|
||||
number, diesize = roll_string.split('d', 1)
|
||||
try:
|
||||
number = int(number)
|
||||
diesize = int(diesize)
|
||||
except Exception:
|
||||
raise TypeError(f"The number and dice-size of '{roll_string}' must be numerical.")
|
||||
if 0 < number > max_number:
|
||||
raise TypeError(f"Invalid number of dice rolled (must be between 1 and {max_number})")
|
||||
if 0 < diesize > max_diesize:
|
||||
raise TypeError(f"Invalid die-size used (must be between 1 and {max_diesize} sides)")
|
||||
|
||||
# At this point we know we have valid input - roll and all dice together
|
||||
return sum(randint(1, diesize) for _ in range(number))
|
||||
|
|
|
|||
|
|
@ -872,13 +872,15 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
|
||||
The `DefaultObject` hooks called (if `move_hooks=True`) are, in order:
|
||||
|
||||
1. `self.at_pre_move(destination)` (if this returns False, move is aborted)
|
||||
2. `source_location.at_object_leave(self, destination)`
|
||||
3. `self.announce_move_from(destination)`
|
||||
4. (move happens here)
|
||||
5. `self.announce_move_to(source_location)`
|
||||
6. `destination.at_object_receive(self, source_location)`
|
||||
7. `self.at_post_move(source_location)`
|
||||
1. `self.at_pre_move(destination)` (abort if return False)
|
||||
2. `source_location.at_pre_object_leave(self, destination)` (abort if return False)
|
||||
3. `destination.at_pre_object_receive(self, source_location)` (abort if return False)
|
||||
4. `source_location.at_object_leave(self, destination)`
|
||||
5. `self.announce_move_from(destination)`
|
||||
6. (move happens here)
|
||||
7. `self.announce_move_to(source_location)`
|
||||
8. `destination.at_object_receive(self, source_location)`
|
||||
9. `self.at_post_move(source_location)`
|
||||
|
||||
"""
|
||||
|
||||
|
|
@ -903,17 +905,33 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
if destination.destination and use_destination:
|
||||
# traverse exits
|
||||
destination = destination.destination
|
||||
# Before the move, call eventual pre-commands.
|
||||
|
||||
# Save the old location
|
||||
source_location = self.location
|
||||
|
||||
# Before the move, call pre-hooks
|
||||
if move_hooks:
|
||||
# check if we are okay to move
|
||||
try:
|
||||
if not self.at_pre_move(destination, **kwargs):
|
||||
return False
|
||||
except Exception as err:
|
||||
logerr(errtxt.format(err="at_pre_move()"), err)
|
||||
return False
|
||||
|
||||
# Save the old location
|
||||
source_location = self.location
|
||||
# check if source location lets us go
|
||||
try:
|
||||
if not source_location.at_pre_object_leave(self, destination, **kwargs):
|
||||
return False
|
||||
except Exception as err:
|
||||
logerr(errtxt.format(err="at_pre_object_leave()"), err)
|
||||
return False
|
||||
# check if destination accepts us
|
||||
try:
|
||||
if not self.at_pre_object_receive(self, source_location, **kwargs):
|
||||
return False
|
||||
except Exception as err:
|
||||
logerr(errtxt.format(err="at_pre_object_receive()"), err)
|
||||
return False
|
||||
|
||||
# Call hook on source location
|
||||
if move_hooks and source_location:
|
||||
|
|
@ -1473,7 +1491,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
def at_pre_move(self, destination, **kwargs):
|
||||
"""
|
||||
Called just before starting to move this object to
|
||||
destination.
|
||||
destination. Return False to abort move.
|
||||
|
||||
Args:
|
||||
destination (Object): The object we are moving to
|
||||
|
|
@ -1481,14 +1499,54 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
overriding the call (unused by default).
|
||||
|
||||
Returns:
|
||||
shouldmove (bool): If we should move or not.
|
||||
bool: If we should move or not.
|
||||
|
||||
Notes:
|
||||
If this method returns False/None, the move is cancelled
|
||||
before it is even started.
|
||||
|
||||
"""
|
||||
# return has_perm(self, destination, "can_move")
|
||||
return True
|
||||
|
||||
def at_pre_object_leave(self, leaving_object, destination, **kwargs):
|
||||
"""
|
||||
Called just before this object is about lose an object that was
|
||||
previously 'inside' it. Return False to abort move.
|
||||
|
||||
Args:
|
||||
leaving_object (Object): The object that is about to leave.
|
||||
destination (Object): Where object is going to.
|
||||
**kwargs (dict): Arbitrary, optional arguments for users
|
||||
overriding the call (unused by default).
|
||||
Returns:
|
||||
bool: If `leaving_object` should be allowed to leave or not.
|
||||
|
||||
Notes: If this method returns False, None, the move is canceled before
|
||||
it even started.
|
||||
|
||||
"""
|
||||
return True
|
||||
|
||||
def at_pre_object_receive(self, arriving_object, source_location, **kwargs):
|
||||
"""
|
||||
Called just before this object received another object. If this
|
||||
method returns `False`, the move is aborted and the moved entity
|
||||
remains where it was.
|
||||
|
||||
Args:
|
||||
arriving_object (Object): The object moved into this one
|
||||
source_location (Object): Where `moved_object` came from.
|
||||
Note that this could be `None`.
|
||||
**kwargs (dict): Arbitrary, optional arguments for users
|
||||
overriding the call (unused by default).
|
||||
|
||||
Returns:
|
||||
bool: If False, abort move and `moved_obj` remains where it was.
|
||||
|
||||
Notes: If this method returns False, None, the move is canceled before
|
||||
it even started.
|
||||
|
||||
"""
|
||||
return True
|
||||
|
||||
# deprecated alias
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ Use as follows:
|
|||
# create a new form from the template
|
||||
form = EvForm("path/to/testform.py")
|
||||
|
||||
(MudForm can also take a dictionary holding
|
||||
(EvForm can also take a dictionary holding
|
||||
the required keys FORMCHAR, TABLECHAR and FORM)
|
||||
|
||||
# add data to each tagged form cell
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ from evennia.utils.utils import (
|
|||
crop,
|
||||
justify,
|
||||
safe_convert_to_types,
|
||||
int2str
|
||||
)
|
||||
from evennia.utils import search
|
||||
from evennia.utils.verb_conjugation.conjugate import verb_actor_stance_components
|
||||
|
|
@ -642,13 +643,45 @@ def funcparser_callable_eval(*args, **kwargs):
|
|||
|
||||
|
||||
def funcparser_callable_toint(*args, **kwargs):
|
||||
"""Usage: toint(43.0) -> 43"""
|
||||
"""Usage: $toint(43.0) -> 43"""
|
||||
inp = funcparser_callable_eval(*args, **kwargs)
|
||||
try:
|
||||
return int(inp)
|
||||
except TypeError:
|
||||
return inp
|
||||
|
||||
def funcparser_callable_int2str(*args, **kwargs):
|
||||
"""
|
||||
Usage: $int2str(1) -> 'one' etc, up to 12->twelve.
|
||||
|
||||
Args:
|
||||
number (int): The number. If not an int, will be converted.
|
||||
|
||||
Uses the int2str utility function.
|
||||
"""
|
||||
if not args:
|
||||
return ""
|
||||
try:
|
||||
number = int(args[0])
|
||||
except ValueError:
|
||||
return args[0]
|
||||
return int2str(number)
|
||||
|
||||
|
||||
def funcparser_callable_an(*args, **kwargs):
|
||||
"""
|
||||
Usage: $an(thing) -> a thing
|
||||
|
||||
Adds a/an depending on if the first letter of the given word is a consonant or not.
|
||||
|
||||
"""
|
||||
if not args:
|
||||
return ""
|
||||
item = str(args[0])
|
||||
if item and item[0] in "aeiouy":
|
||||
return f"an {item}"
|
||||
return f"a {item}"
|
||||
|
||||
|
||||
def _apply_operation_two_elements(*args, operator="+", **kwargs):
|
||||
"""
|
||||
|
|
@ -987,6 +1020,35 @@ def funcparser_callable_clr(*args, **kwargs):
|
|||
endclr = "|" + endclr if endclr else ("|n" if startclr else "")
|
||||
return f"{startclr}{text}{endclr}"
|
||||
|
||||
def funcparser_callable_pluralize(*args, **kwargs):
|
||||
"""
|
||||
FuncParser callable. Handles pluralization of a word.
|
||||
|
||||
Args:
|
||||
singular_word (str): The base (singular) word to optionally pluralize
|
||||
number (int): The number of elements; if 1 (or 0), use `singular_word` as-is,
|
||||
otherwise use plural form.
|
||||
plural_word (str, optional): If given, this will be used if `number`
|
||||
is greater than one. If not given, we simply add 's' to the end of
|
||||
`singular_word'.
|
||||
|
||||
Example:
|
||||
- `$pluralize(thing, 2)` -> "things"
|
||||
- `$pluralize(goose, 18, geese)` -> "geese"
|
||||
|
||||
"""
|
||||
if not args:
|
||||
return ""
|
||||
nargs = len(args)
|
||||
if nargs > 2:
|
||||
singular_word, number, plural_word = args[:3]
|
||||
elif nargs > 1:
|
||||
singular_word, number = args[:2]
|
||||
plural_word = f"{singular_word}s"
|
||||
else:
|
||||
singular_word, number = args[0], 1
|
||||
return singular_word if abs(int(number)) in (0, 1) else plural_word
|
||||
|
||||
|
||||
def funcparser_callable_search(*args, caller=None, access="control", **kwargs):
|
||||
"""
|
||||
|
|
@ -1353,6 +1415,9 @@ FUNCPARSER_CALLABLES = {
|
|||
"justify_center": funcparser_callable_center_justify,
|
||||
"space": funcparser_callable_space,
|
||||
"clr": funcparser_callable_clr,
|
||||
"pluralize": funcparser_callable_pluralize,
|
||||
"int2str": funcparser_callable_int2str,
|
||||
"an": funcparser_callable_an,
|
||||
}
|
||||
|
||||
SEARCHING_CALLABLES = {
|
||||
|
|
|
|||
|
|
@ -391,6 +391,13 @@ class TestDefaultCallables(TestCase):
|
|||
("Some $rjust(Hello, width=30)", "Some Hello"),
|
||||
("Some $cjust(Hello, 30)", "Some Hello "),
|
||||
("Some $eval('-'*20)Hello", "Some --------------------Hello"),
|
||||
("There $pluralize(is, 1, are) one $pluralize(goose, 1, geese) here.",
|
||||
"There is one goose here."),
|
||||
("There $pluralize(is, 2, are) two $pluralize(goose, 2, geese) here.",
|
||||
"There are two geese here."),
|
||||
("There is $int2str(1) murderer, but $int2str(12) suspects.",
|
||||
"There is one murderer, but twelve suspects."),
|
||||
("There is $an(thing) here", "There is a thing here"),
|
||||
]
|
||||
)
|
||||
def test_other_callables(self, string, expected):
|
||||
|
|
|
|||
|
|
@ -2711,3 +2711,26 @@ def run_in_main_thread(function_or_method, *args, **kwargs):
|
|||
return function_or_method(*args, **kwargs)
|
||||
else:
|
||||
return threads.blockingCallFromThread(reactor, function_or_method, *args, **kwargs)
|
||||
|
||||
|
||||
_INT2STR_MAP_NOUN = {1: "one", 2: "two", 3: "three", 4: "four", 5: "five", 6: "six",
|
||||
7: "seven", 8: "eight", 9: "nine", 10: "ten", 11: "eleven", 12: "twelve"}
|
||||
_INT2STR_MAP_ADJ = {1: "1st", 2: "2nd", 3: "3rd"} # rest is Xth.
|
||||
|
||||
def int2str(self, number, adjective=False):
|
||||
"""
|
||||
Convert a number to an English string for better display; so 1 -> one, 2 -> two etc
|
||||
up until 12, after which it will be '13', '14' etc.
|
||||
|
||||
Args:
|
||||
number (int): The number to convert. Floats will be converted to ints.
|
||||
adjective (int): If set, map 1->1st, 2->2nd etc. If unset, map 1->one, 2->two etc.
|
||||
up to twelve.
|
||||
Return:
|
||||
str: The number expressed as a string.
|
||||
|
||||
"""
|
||||
number = int(adjective)
|
||||
if adjective:
|
||||
return _INT2STR_MAP_ADJ.get(number, f"{number}th")
|
||||
return _INT2STR_MAP_NOUN.get(number, str(number))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue