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)
|
- Attribute storage support defaultdics (Hendher)
|
||||||
- Add ObjectParent mixin to default game folder template as an easy, ready-made
|
- Add ObjectParent mixin to default game folder template as an easy, ready-made
|
||||||
way to override features on all ObjectDB-inheriting objects easily.
|
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
|
## 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).
|
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.
|
- Roll-engine: Class with methods for making all dice rolls needed by the rules. Knave only
|
||||||
- Improvement - these are rules used with experience to improve the character
|
has a few resolution rolls, but we define helper methods for different actions the
|
||||||
over time.
|
character will be able to do in-code.
|
||||||
- Actions - all in-game interactions (making use of the character's abilities)
|
- Character generation - this is a container used for holding, tweaking and setting
|
||||||
are defined as discreet _actions_ in the game. An action is the smallest rule
|
all character data during character generation. At the end it will save itself
|
||||||
unit to accomplish something with rule support. While in a tabletop game you
|
onto the Character for permanent storage.
|
||||||
have a human game master to arbitrate, the computer requires exactness. While
|
- Improvement - this container holds rules used with experience to improve the
|
||||||
free-form roleplay is also possible, only the actions defined here will have a
|
character over time.
|
||||||
coded support.
|
- 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 dataclasses import dataclass
|
||||||
|
from evennia.utils.evform import EvForm
|
||||||
|
from evennia.utils.evtable import EvTable
|
||||||
from .utils import roll
|
from .utils import roll
|
||||||
from .random_tables import character_generation as chargen_table
|
from .random_tables import character_generation as chargen_table
|
||||||
|
|
||||||
|
|
||||||
# Basic rolls
|
# Basic rolls
|
||||||
|
|
||||||
def saving_throw(bonus, advantage=False, disadvantage=False):
|
class EvAdventureRollEngine:
|
||||||
"""
|
"""
|
||||||
To save, roll d20 + relevant Attrribute bonus > 15 (always 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.
|
||||||
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.
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
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():
|
Args:
|
||||||
"""
|
roll_string (str): A roll using standard rpg syntax, <number>d<diesize>, like
|
||||||
For the MUD version, we use a flat bonus and let the user redistribute it. This
|
1d6, 2d10 etc. Max die-size is 1000.
|
||||||
function (unused by default) implements the original Knave random generator for
|
max_number (int): The max number of dice to roll. Defaults to 10, which is usually
|
||||||
the Attribute bonus, if you prefer producing more 'unbalanced' characters.
|
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:
|
Raises:
|
||||||
int: The randomly generated Attribute bonus.
|
TypeError: If roll_string is not on the right format or otherwise doesn't validate.
|
||||||
|
|
||||||
"""
|
Notes:
|
||||||
return min(roll("1d6"), roll("1d6"), roll("1d6"))
|
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):
|
# At this point we know we have valid input - roll and all dice together
|
||||||
"""
|
return sum(randint(1, diesize) for _ in range(number))
|
||||||
Make a roll on a random table.
|
|
||||||
|
|
||||||
Args:
|
@staticmethod
|
||||||
dieroll (str): The dice to roll, like 1d6, 1d20, 3d6 etc).
|
def roll_with_advantage_or_disadvantage(advantage=False, disadvantage=False):
|
||||||
table_choices (iterable): If a list of single elements, the die roll
|
"""
|
||||||
should fully encompass the table, like a 1d20 roll for a table
|
Base roll of d20, or 2d20, based on dis/advantage given.
|
||||||
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:
|
Args:
|
||||||
Any: The result of the random roll.
|
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:
|
Notes:
|
||||||
`roll table_choices = [('1-5', "Blue"), ('6-9': "Red"), ('10', "Purple")]`
|
Disadvantage and advantage cancel each other out.
|
||||||
|
|
||||||
Notes:
|
"""
|
||||||
If the roll is outside of the listing, the closest edge value is used.
|
if not (advantage or disadvantage) or (advantage and disadvantage):
|
||||||
|
# normal roll
|
||||||
"""
|
return roll("1d20")
|
||||||
roll_result = roll(dieroll)
|
elif advantage:
|
||||||
|
return max(roll("1d20"), roll("1d20"))
|
||||||
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:
|
else:
|
||||||
return min_range
|
return min(roll("1d20"), roll("1d20"))
|
||||||
else:
|
|
||||||
# regular list - one line per value.
|
@staticmethod
|
||||||
roll_result = max(1, min(len(table_choices), roll_result))
|
def saving_throw(character, bonus_type='strength',
|
||||||
return table_choices[roll_result - 1]
|
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
|
# character generation
|
||||||
|
|
||||||
@dataclass
|
class EvAdventureCharacterGeneration:
|
||||||
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:
|
|
||||||
"""
|
"""
|
||||||
This collects all the rules for generating a new character. An instance of this class can be
|
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
|
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
|
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
|
# 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
|
# base attribute bonuses
|
||||||
self.strength = CharAttribute(bonus=2)
|
self.strength = 2
|
||||||
self.dexterity = CharAttribute(bonus=2)
|
self.dexterity = 2
|
||||||
self.constitution = CharAttribute(bonus=2)
|
self.constitution = 2
|
||||||
self.intelligence = CharAttribute(bonus=2)
|
self.intelligence = 2
|
||||||
self.wisdom = CharAttribute(bonus=2)
|
self.wisdom = 2
|
||||||
self.charisma = CharAttribute(bonus=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)
|
# physical attributes (only for rp purposes)
|
||||||
self.physique = roll_random_table('1d20', chargen_table['physique'])
|
self.physique = dice.roll_random_table('1d20', chargen_table['physique'])
|
||||||
self.face = roll_random_table(chargen_table['1d20', 'face'])
|
self.face = dice.roll_random_table('1d20', chargen_table['face'])
|
||||||
self.skin = roll_random_table(chargen_table['1d20', 'skin'])
|
self.skin = dice.roll_random_table('1d20', chargen_table['skin'])
|
||||||
self.hair = roll_random_table(chargen_table['1d20', 'hair'])
|
self.hair = dice.roll_random_table('1d20', chargen_table['hair'])
|
||||||
self.clothing = roll_random_table(chargen_table['1d20', 'clothing'])
|
self.clothing = dice.roll_random_table('1d20', chargen_table['clothing'])
|
||||||
self.virtue = roll_random_table(chargen_table['1d20', 'virtue'])
|
self.speech = dice.roll_random_table('1d20', chargen_table['speech'])
|
||||||
self.vice = roll_random_table(chargen_table['1d20', 'vice'])
|
self.virtue = dice.roll_random_table('1d20', chargen_table['virtue'])
|
||||||
self.background = roll_random_table(chargen_table['1d20', 'background'])
|
self.vice = dice.roll_random_table('1d20', chargen_table['vice'])
|
||||||
self.misfortune = roll_random_table(chargen_table['1d20', 'misfortune'])
|
self.background = dice.roll_random_table('1d20', chargen_table['background'])
|
||||||
self.alignment = roll_random_table(chargen_table['1d20', 'alignment'])
|
self.misfortune = dice.roll_random_table('1d20', chargen_table['misfortune'])
|
||||||
|
self.alignment = dice.roll_random_table('1d20', chargen_table['alignment'])
|
||||||
|
|
||||||
# same for all
|
# same for all
|
||||||
self.exploration_speed = 120
|
self.exploration_speed = 120
|
||||||
self.combat_speed = 40
|
self.combat_speed = 40
|
||||||
self.hp = 0
|
self.hp_max = 8
|
||||||
|
self.hp = self.hp_max
|
||||||
self.xp = 0
|
self.xp = 0
|
||||||
self.level = 1
|
self.level = 1
|
||||||
|
|
||||||
# random equipment
|
# 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.helmet = "helmet" if "helmet" in _helmet_and_shield else "none"
|
||||||
self.shield = "shield" if "shield" 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 = [
|
self.equipment = [
|
||||||
"ration",
|
"ration",
|
||||||
"ration",
|
"ration",
|
||||||
roll_random_table(chargen_table['1d20', "dungeoning gear"]),
|
dice.roll_random_table(chargen_table['1d20', "dungeoning gear"]),
|
||||||
roll_random_table(chargen_table['1d20', "dungeoning gear"]),
|
dice.roll_random_table(chargen_table['1d20', "dungeoning gear"]),
|
||||||
roll_random_table(chargen_table['1d20', "general gear 1"]),
|
dice.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', "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):
|
def adjust_attribute(self, source_attribute, target_attribute, value):
|
||||||
"""
|
"""
|
||||||
Redistribute bonus from one attribute to another. The resulting values
|
Redistribute bonus from one attribute to another. The resulting values
|
||||||
|
|
@ -257,3 +425,175 @@ class CharacterGeneration:
|
||||||
permanently.
|
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.
|
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:
|
The `DefaultObject` hooks called (if `move_hooks=True`) are, in order:
|
||||||
|
|
||||||
1. `self.at_pre_move(destination)` (if this returns False, move is aborted)
|
1. `self.at_pre_move(destination)` (abort if return False)
|
||||||
2. `source_location.at_object_leave(self, destination)`
|
2. `source_location.at_pre_object_leave(self, destination)` (abort if return False)
|
||||||
3. `self.announce_move_from(destination)`
|
3. `destination.at_pre_object_receive(self, source_location)` (abort if return False)
|
||||||
4. (move happens here)
|
4. `source_location.at_object_leave(self, destination)`
|
||||||
5. `self.announce_move_to(source_location)`
|
5. `self.announce_move_from(destination)`
|
||||||
6. `destination.at_object_receive(self, source_location)`
|
6. (move happens here)
|
||||||
7. `self.at_post_move(source_location)`
|
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:
|
if destination.destination and use_destination:
|
||||||
# traverse exits
|
# traverse exits
|
||||||
destination = destination.destination
|
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:
|
if move_hooks:
|
||||||
|
# check if we are okay to move
|
||||||
try:
|
try:
|
||||||
if not self.at_pre_move(destination, **kwargs):
|
if not self.at_pre_move(destination, **kwargs):
|
||||||
return False
|
return False
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logerr(errtxt.format(err="at_pre_move()"), err)
|
logerr(errtxt.format(err="at_pre_move()"), err)
|
||||||
return False
|
return False
|
||||||
|
# check if source location lets us go
|
||||||
# Save the old location
|
try:
|
||||||
source_location = self.location
|
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
|
# Call hook on source location
|
||||||
if move_hooks and source_location:
|
if move_hooks and source_location:
|
||||||
|
|
@ -1473,7 +1491,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
||||||
def at_pre_move(self, destination, **kwargs):
|
def at_pre_move(self, destination, **kwargs):
|
||||||
"""
|
"""
|
||||||
Called just before starting to move this object to
|
Called just before starting to move this object to
|
||||||
destination.
|
destination. Return False to abort move.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
destination (Object): The object we are moving to
|
destination (Object): The object we are moving to
|
||||||
|
|
@ -1481,14 +1499,54 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
||||||
overriding the call (unused by default).
|
overriding the call (unused by default).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
shouldmove (bool): If we should move or not.
|
bool: If we should move or not.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
If this method returns False/None, the move is cancelled
|
If this method returns False/None, the move is cancelled
|
||||||
before it is even started.
|
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
|
return True
|
||||||
|
|
||||||
# deprecated alias
|
# deprecated alias
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ Use as follows:
|
||||||
# create a new form from the template
|
# create a new form from the template
|
||||||
form = EvForm("path/to/testform.py")
|
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)
|
the required keys FORMCHAR, TABLECHAR and FORM)
|
||||||
|
|
||||||
# add data to each tagged form cell
|
# add data to each tagged form cell
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ from evennia.utils.utils import (
|
||||||
crop,
|
crop,
|
||||||
justify,
|
justify,
|
||||||
safe_convert_to_types,
|
safe_convert_to_types,
|
||||||
|
int2str
|
||||||
)
|
)
|
||||||
from evennia.utils import search
|
from evennia.utils import search
|
||||||
from evennia.utils.verb_conjugation.conjugate import verb_actor_stance_components
|
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):
|
def funcparser_callable_toint(*args, **kwargs):
|
||||||
"""Usage: toint(43.0) -> 43"""
|
"""Usage: $toint(43.0) -> 43"""
|
||||||
inp = funcparser_callable_eval(*args, **kwargs)
|
inp = funcparser_callable_eval(*args, **kwargs)
|
||||||
try:
|
try:
|
||||||
return int(inp)
|
return int(inp)
|
||||||
except TypeError:
|
except TypeError:
|
||||||
return inp
|
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):
|
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 "")
|
endclr = "|" + endclr if endclr else ("|n" if startclr else "")
|
||||||
return f"{startclr}{text}{endclr}"
|
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):
|
def funcparser_callable_search(*args, caller=None, access="control", **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|
@ -1353,6 +1415,9 @@ FUNCPARSER_CALLABLES = {
|
||||||
"justify_center": funcparser_callable_center_justify,
|
"justify_center": funcparser_callable_center_justify,
|
||||||
"space": funcparser_callable_space,
|
"space": funcparser_callable_space,
|
||||||
"clr": funcparser_callable_clr,
|
"clr": funcparser_callable_clr,
|
||||||
|
"pluralize": funcparser_callable_pluralize,
|
||||||
|
"int2str": funcparser_callable_int2str,
|
||||||
|
"an": funcparser_callable_an,
|
||||||
}
|
}
|
||||||
|
|
||||||
SEARCHING_CALLABLES = {
|
SEARCHING_CALLABLES = {
|
||||||
|
|
|
||||||
|
|
@ -391,6 +391,13 @@ class TestDefaultCallables(TestCase):
|
||||||
("Some $rjust(Hello, width=30)", "Some Hello"),
|
("Some $rjust(Hello, width=30)", "Some Hello"),
|
||||||
("Some $cjust(Hello, 30)", "Some Hello "),
|
("Some $cjust(Hello, 30)", "Some Hello "),
|
||||||
("Some $eval('-'*20)Hello", "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):
|
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)
|
return function_or_method(*args, **kwargs)
|
||||||
else:
|
else:
|
||||||
return threads.blockingCallFromThread(reactor, function_or_method, *args, **kwargs)
|
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