diff --git a/CHANGELOG.md b/CHANGELOG.md index 6268fa57ec..314c4be37e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/evennia/contrib/tutorials/evadventure/characters.py b/evennia/contrib/tutorials/evadventure/characters.py index e69de29bb2..bd397db48c 100644 --- a/evennia/contrib/tutorials/evadventure/characters.py +++ b/evennia/contrib/tutorials/evadventure/characters.py @@ -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 + diff --git a/evennia/contrib/tutorials/evadventure/combat_turnbased.py b/evennia/contrib/tutorials/evadventure/combat_turnbased.py new file mode 100644 index 0000000000..ee2276ee52 --- /dev/null +++ b/evennia/contrib/tutorials/evadventure/combat_turnbased.py @@ -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() diff --git a/evennia/contrib/tutorials/evadventure/combat.py b/evennia/contrib/tutorials/evadventure/combat_twitch.py similarity index 100% rename from evennia/contrib/tutorials/evadventure/combat.py rename to evennia/contrib/tutorials/evadventure/combat_twitch.py diff --git a/evennia/contrib/tutorials/evadventure/objects.py b/evennia/contrib/tutorials/evadventure/objects.py index e69de29bb2..2c47ef5e70 100644 --- a/evennia/contrib/tutorials/evadventure/objects.py +++ b/evennia/contrib/tutorials/evadventure/objects.py @@ -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). + + """ diff --git a/evennia/contrib/tutorials/evadventure/random_tables.py b/evennia/contrib/tutorials/evadventure/random_tables.py index 19604ec922..d1a2cc493f 100644 --- a/evennia/contrib/tutorials/evadventure/random_tables.py +++ b/evennia/contrib/tutorials/evadventure/random_tables.py @@ -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"), +] + diff --git a/evennia/contrib/tutorials/evadventure/rules.py b/evennia/contrib/tutorials/evadventure/rules.py index b8fc1e8816..b735ea1600 100644 --- a/evennia/contrib/tutorials/evadventure/rules.py +++ b/evennia/contrib/tutorials/evadventure/rules.py @@ -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, d, 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 `d`.") + 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() diff --git a/evennia/contrib/tutorials/evadventure/tests.py b/evennia/contrib/tutorials/evadventure/tests.py new file mode 100644 index 0000000000..52b0b1e279 --- /dev/null +++ b/evennia/contrib/tutorials/evadventure/tests.py @@ -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 + diff --git a/evennia/contrib/tutorials/evadventure/utils.py b/evennia/contrib/tutorials/evadventure/utils.py index 13e145d108..fc7da8fb52 100644 --- a/evennia/contrib/tutorials/evadventure/utils.py +++ b/evennia/contrib/tutorials/evadventure/utils.py @@ -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, d, 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 `d`.") - 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)) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index cd629bd22a..46e0a61a14 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -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 diff --git a/evennia/utils/evform.py b/evennia/utils/evform.py index 7bbe7a4915..fa55e6b95b 100644 --- a/evennia/utils/evform.py +++ b/evennia/utils/evform.py @@ -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 diff --git a/evennia/utils/funcparser.py b/evennia/utils/funcparser.py index e0ae1d3eb6..cde44fa113 100644 --- a/evennia/utils/funcparser.py +++ b/evennia/utils/funcparser.py @@ -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 = { diff --git a/evennia/utils/tests/test_funcparser.py b/evennia/utils/tests/test_funcparser.py index c62928ff53..4e01e34eca 100644 --- a/evennia/utils/tests/test_funcparser.py +++ b/evennia/utils/tests/test_funcparser.py @@ -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): diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index d603a93bd2..de2e0cb695 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -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))