Continue with evadventure implementation

This commit is contained in:
Griatch 2022-03-21 15:35:07 +01:00
parent a07ef8e3c4
commit a553f1ab2f
14 changed files with 1296 additions and 190 deletions

View file

@ -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

View file

@ -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

View 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()

View file

@ -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).
"""

View file

@ -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"),
]

View file

@ -1,142 +1,292 @@
"""
MUD ruleset based on the _Knave_ OSR tabletop RPG by Ben Milton (modified for MUD use).
The rules are divided into three parts:
The rules are divided into a set of classes. While each class (except chargen) could
also have been stand-alone functions, having them as classes makes it a little easier
to use them as the base for your own variation (tweaking values etc).
- Character generation - these are rules only used when creating a character.
- Improvement - these are rules used with experience to improve the character
over time.
- Actions - all in-game interactions (making use of the character's abilities)
are defined as discreet _actions_ in the game. An action is the smallest rule
unit to accomplish something with rule support. While in a tabletop game you
have a human game master to arbitrate, the computer requires exactness. While
free-form roleplay is also possible, only the actions defined here will have a
coded support.
- Roll-engine: Class with methods for making all dice rolls needed by the rules. Knave only
has a few resolution rolls, but we define helper methods for different actions the
character will be able to do in-code.
- Character generation - this is a container used for holding, tweaking and setting
all character data during character generation. At the end it will save itself
onto the Character for permanent storage.
- Improvement - this container holds rules used with experience to improve the
character over time.
- Charsheet - a container with tools for visually displaying the character sheet in-game.
This module presents several singletons to import
- `dice` - the `EvAdventureRollEngine` for all random resolution and table-rolling.
- `character_sheet` - the `EvAdventureCharacterSheet` visualizer.
- `improvement` - the EvAdventureImprovement` class for handling char xp and leveling.
"""
from random import randint
from dataclasses import dataclass
from evennia.utils.evform import EvForm
from evennia.utils.evtable import EvTable
from .utils import roll
from .random_tables import character_generation as chargen_table
# Basic rolls
def saving_throw(bonus, advantage=False, disadvantage=False):
class EvAdventureRollEngine:
"""
To save, roll d20 + relevant Attrribute bonus > 15 (always 15).
Args:
advantage (bool): Roll 2d20 and use the bigger number.
disadvantage (bool): Roll 2d20 and use the smaller number.
Returns:
bool: If the save was passed or not.
Notes:
Advantage and disadvantage cancel each other out.
Example:
Trying to overcome the effects of poison, roll d20 + Constitution-bonus above 15.
This groups all dice rolls of EvAdventure. These could all have been normal functions, but we
are group them in a class to make them easier to partially override and replace later.
"""
if not (advantage or disadvantage) or (advantage and disadvantage):
# normal roll
dice_roll = roll("1d20")
elif advantage:
dice_roll = max(roll("1d20"), roll("1d20"))
else:
dice_roll = min(roll("1d20"), roll("1d20"))
return (dice_roll + bonus) > 15
@staticmethod
def roll(roll_string, max_number=10):
"""
NOTE: In evennia/contribs/rpg/dice/ is a more powerful dice roller with
more features, such as modifiers, secret rolls etc. This is much simpler and only
gets a simple sum of normal rpg-dice.
def roll_attribute_bonus():
"""
For the MUD version, we use a flat bonus and let the user redistribute it. This
function (unused by default) implements the original Knave random generator for
the Attribute bonus, if you prefer producing more 'unbalanced' characters.
Args:
roll_string (str): A roll using standard rpg syntax, <number>d<diesize>, like
1d6, 2d10 etc. Max die-size is 1000.
max_number (int): The max number of dice to roll. Defaults to 10, which is usually
more than enough.
The Attribute bonus is generated by rolling the lowest value of 3d6.
Returns:
int: The rolled result - sum of all dice rolled.
Returns:
int: The randomly generated Attribute bonus.
Raises:
TypeError: If roll_string is not on the right format or otherwise doesn't validate.
"""
return min(roll("1d6"), roll("1d6"), roll("1d6"))
Notes:
Since we may see user input to this function, we make sure to validate the inputs (we
wouldn't bother much with that if it was just for developer use).
"""
max_diesize = 1000
roll_string = roll_string.lower()
if 'd' not in roll_string:
raise TypeError(f"Dice roll '{roll_string}' was not recognized. "
"Must be `<number>d<dicesize>`.")
number, diesize = roll_string.split('d', 1)
try:
number = int(number)
diesize = int(diesize)
except Exception:
raise TypeError(f"The number and dice-size of '{roll_string}' must be numerical.")
if 0 < number > max_number:
raise TypeError(f"Invalid number of dice rolled (must be between 1 and {max_number})")
if 0 < diesize > max_diesize:
raise TypeError(f"Invalid die-size used (must be between 1 and {max_diesize} sides)")
def roll_random_table(dieroll, table, table_choices):
"""
Make a roll on a random table.
# At this point we know we have valid input - roll and all dice together
return sum(randint(1, diesize) for _ in range(number))
Args:
dieroll (str): The dice to roll, like 1d6, 1d20, 3d6 etc).
table_choices (iterable): If a list of single elements, the die roll
should fully encompass the table, like a 1d20 roll for a table
with 20 elements. If each element is a tuple, the first element
of the tuple is assumed to be a string 'X-Y' indicating the
range of values that should match the roll.
@staticmethod
def roll_with_advantage_or_disadvantage(advantage=False, disadvantage=False):
"""
Base roll of d20, or 2d20, based on dis/advantage given.
Returns:
Any: The result of the random roll.
Args:
bonus (int): The ability bonus to apply, like strength or charisma.
advantage (bool): Roll 2d20 and use the bigger number.
disadvantage (bool): Roll 2d20 and use the smaller number.
Example:
`roll table_choices = [('1-5', "Blue"), ('6-9': "Red"), ('10', "Purple")]`
Notes:
Disadvantage and advantage cancel each other out.
Notes:
If the roll is outside of the listing, the closest edge value is used.
"""
roll_result = roll(dieroll)
if isinstance(table_choices[0], (tuple, list)):
# tuple with range conditional, like ('1-5', "Blue") or ('10', "Purple")
max_range = -1
min_range = 10**6
for (valrange, choice) in table_choices:
minval, *maxval = valrange.split('-', 1)
minval = abs(int(minval))
maxval = abs(int(maxval[0]) if maxval else minval)
# we store the largest/smallest values so far in case we need to use them
max_range = max(max_range, maxval)
min_range = min(min_range, minval)
if minval <= roll_result <= maxval:
return choice
# if we have no result, we are outside of the range, we pick the edge values. It is also
# possible the range contains 'gaps', but that'd be an error in the random table itself.
if roll_result > max_range:
return max_range
"""
if not (advantage or disadvantage) or (advantage and disadvantage):
# normal roll
return roll("1d20")
elif advantage:
return max(roll("1d20"), roll("1d20"))
else:
return min_range
else:
# regular list - one line per value.
roll_result = max(1, min(len(table_choices), roll_result))
return table_choices[roll_result - 1]
return min(roll("1d20"), roll("1d20"))
@staticmethod
def saving_throw(character, bonus_type='strength',
advantage=False, disadvantage=False, modifier=0):
"""
A saving throw without a clear enemy to beat. In _Knave_ all unopposed saving
throws always tries to beat 15, so (d20 + bonus + modifier) > 15.
Args:
character (Object): The one attempting to save themselves.
bonus (str): The ability bonus to apply, like strength or charisma. Minimum is 1.
advantage (bool): Roll 2d20 and use the bigger number.
disadvantage (bool): Roll 2d20 and use the smaller number.
modifier (int): An additional +/- modifier to the roll.
Returns:
tuple: (bool, str): If the save was passed or not. The second element is the
quality of the roll - None (normal), "critical fail" and "critical success".
Notes:
Advantage and disadvantage cancel each other out.
Example:
Trying to overcome the effects of poison, roll d20 + Constitution-bonus above 15.
"""
bonus = getattr(character, bonus_type, 1)
dice_roll = roll_with_advantage_or_disadvantage(advantage, disadvantage)
if dice_roll == 1:
quality = "critical failure"
elif dice_roll == 20:
quality = "critical success"
else:
quality = None
return (dice_roll + bonus + modifier) > 15, quality
@staticmethod
def opposed_saving_throw(attacker, defender, attack_type='strength', defense_type='armor',
advantage=False, disadvantage=False):
"""
An saving throw that tries to beat an active opposing side.
Args:
attacker (Character): The attacking party.
defender (Character): The one defending.
attack_type (str): Which ability to use in the attack, like 'strength' or 'willpower'.
Minimum is always 1.
defense_type (str): Which ability to defend with, in addition to 'armor'.
Minimum is always 11 (bonus + 10 is always the defense in _Knave_).
advantage (bool): Roll 2d20 and use the bigger number.
disadvantage (bool): Roll 2d20 and use the smaller number.
modifier (int): An additional +/- modifier to the roll.
Returns:
tuple: (bool, str): If the attack succeed or not. The second element is the
quality of the roll - None (normal), "critical fail" and "critical success".
Notes:
Advantage and disadvantage cancel each other out.
"""
attack_bonus = getattr(attacker, attack_type, 1)
# defense is always bonus + 10 in Knave
defender_defense = getattr(defender, defense_type_type, 1) + 10
dice_roll = roll_with_advantage_or_disadvantage(advantage, disadvantage)
if dice_roll == 1:
quality = "critical failure"
elif dice_roll == 20:
quality = "critical success"
else:
quality = None
return (dice_roll + attack_bonus + modifier) > defender_defense, quality
# specific rolls / actions
@staticmethod
def melee_attack(attacker, defender, advantage=False, disadvantage=False):
"""Close attack (strength vs armor)"""
return opposed_saving_throw(
attacker, defender, attack_type="strength", defense_type="armor",
advantage=advantage, disadvantage=disadvantage)
@staticmethod
def ranged_attack(attacker, defender, advantage=False, disadvantage=False):
"""Ranged attack (wisdom vs armor)"""
return opposed_saving_throw(
attacker, defender, attack_type="wisdom", defense_type="armor",
advantage=advantage, disadvantage=disadvantage)
@staticmethod
def magic_attack(attacker, defender, advantage=False, disadvantage=False):
"""Magic attack (int vs dexterity)"""
return opposed_saving_throw(
attacker, defender, attack_type="intelligence", defense_type="dexterity",
advantage=advantage, disadvantage=disadvantage)
@staticmethod
def morale_check(defender):
"""
A morale check is done for NPCs/monsters. It's done with a 2d6 against
their morale.
Args:
defender (NPC): The entity trying to defend its morale.
Returns:
bool: False if morale roll failed, True otherwise.
"""
return roll('2d6') <= defender.morale
@staticmethod
def healing_from_rest(character):
"""
A meal and a full night's rest allow for regaining 1d8 + Const bonus HP.
Args:
character (Character): The one resting.
Returns:
int: How much HP was healed. This is never more than how damaged we are.
"""
# we can't heal more than our damage
damage = character.hp_max - character.hp
healed = roll('1d8') + character.constitution
return min(damage, healed)
@staticmethod
def roll_random_table(dieroll, table, table_choices):
"""
Make a roll on a random table.
Args:
dieroll (str): The dice to roll, like 1d6, 1d20, 3d6 etc).
table_choices (iterable): If a list of single elements, the die roll
should fully encompass the table, like a 1d20 roll for a table
with 20 elements. If each element is a tuple, the first element
of the tuple is assumed to be a string 'X-Y' indicating the
range of values that should match the roll.
Returns:
Any: The result of the random roll.
Example:
`roll table_choices = [('1-5', "Blue"), ('6-9': "Red"), ('10', "Purple")]`
Notes:
If the roll is outside of the listing, the closest edge value is used.
"""
roll_result = roll(dieroll)
if isinstance(table_choices[0], (tuple, list)):
# tuple with range conditional, like ('1-5', "Blue") or ('10', "Purple")
max_range = -1
min_range = 10**6
for (valrange, choice) in table_choices:
minval, *maxval = valrange.split('-', 1)
minval = abs(int(minval))
maxval = abs(int(maxval[0]) if maxval else minval)
# we store the largest/smallest values so far in case we need to use them
max_range = max(max_range, maxval)
min_range = min(min_range, minval)
if minval <= roll_result <= maxval:
return choice
# if we have no result, we are outside of the range, we pick the edge values. It is also
# possible the range contains 'gaps', but that'd be an error in the random table itself.
if roll_result > max_range:
return max_range
else:
return min_range
else:
# regular list - one line per value.
roll_result = max(1, min(len(table_choices), roll_result))
return table_choices[roll_result - 1]
# character generation
@dataclass
class CharAttribute:
"""
A character Attribute, like strength or wisdom, has a _bonus_, used
to improve the result of doing a related action. It also has a _defense_ value
which is always 10 points higher than the bonus. For example, to attack
someone, you'd have to roll d20 + `strength bonus` to beat the `strength defense`
of the enemy.
"""
bonus: str = 0
@property
def defense(self):
return bonus + 10
class CharacterGeneration:
class EvAdventureCharacterGeneration:
"""
This collects all the rules for generating a new character. An instance of this class can be
used to track all the stats during generation and will be used to apply all the data to the
@ -170,56 +320,74 @@ class CharacterGeneration:
Initialize starting values
"""
# for clarity we initialize the engine here rather than use the
# global singleton at the end of the module
dice = EvAdventureRollEngine()
# name will likely be modified later
self.name = roll_random_table('1d282', chargen_table['name'])
self.name = dice.roll_random_table('1d282', chargen_table['name'])
# base attribute bonuses
self.strength = CharAttribute(bonus=2)
self.dexterity = CharAttribute(bonus=2)
self.constitution = CharAttribute(bonus=2)
self.intelligence = CharAttribute(bonus=2)
self.wisdom = CharAttribute(bonus=2)
self.charisma = CharAttribute(bonus=2)
self.strength = 2
self.dexterity = 2
self.constitution = 2
self.intelligence = 2
self.wisdom = 2
self.charisma = 2
self.armor = CharAttribute(bonus=1) # un-armored default
self.armor_bonus = 1 # un-armored default
# physical attributes (only for rp purposes)
self.physique = roll_random_table('1d20', chargen_table['physique'])
self.face = roll_random_table(chargen_table['1d20', 'face'])
self.skin = roll_random_table(chargen_table['1d20', 'skin'])
self.hair = roll_random_table(chargen_table['1d20', 'hair'])
self.clothing = roll_random_table(chargen_table['1d20', 'clothing'])
self.virtue = roll_random_table(chargen_table['1d20', 'virtue'])
self.vice = roll_random_table(chargen_table['1d20', 'vice'])
self.background = roll_random_table(chargen_table['1d20', 'background'])
self.misfortune = roll_random_table(chargen_table['1d20', 'misfortune'])
self.alignment = roll_random_table(chargen_table['1d20', 'alignment'])
self.physique = dice.roll_random_table('1d20', chargen_table['physique'])
self.face = dice.roll_random_table('1d20', chargen_table['face'])
self.skin = dice.roll_random_table('1d20', chargen_table['skin'])
self.hair = dice.roll_random_table('1d20', chargen_table['hair'])
self.clothing = dice.roll_random_table('1d20', chargen_table['clothing'])
self.speech = dice.roll_random_table('1d20', chargen_table['speech'])
self.virtue = dice.roll_random_table('1d20', chargen_table['virtue'])
self.vice = dice.roll_random_table('1d20', chargen_table['vice'])
self.background = dice.roll_random_table('1d20', chargen_table['background'])
self.misfortune = dice.roll_random_table('1d20', chargen_table['misfortune'])
self.alignment = dice.roll_random_table('1d20', chargen_table['alignment'])
# same for all
self.exploration_speed = 120
self.combat_speed = 40
self.hp = 0
self.hp_max = 8
self.hp = self.hp_max
self.xp = 0
self.level = 1
# random equipment
self.armor = roll_random_table('1d20', chargen_table['armor'])
self.armor = dice.roll_random_table('1d20', chargen_table['armor'])
_helmet_and_shield = roll_random_table('1d20', chargen_table["helmets and shields"])
_helmet_and_shield = dice.roll_random_table('1d20', chargen_table["helmets and shields"])
self.helmet = "helmet" if "helmet" in _helmet_and_shield else "none"
self.shield = "shield" if "shield" in _helmet_and_shield else "none"
self.weapon = roll_random_table(chargen_table['1d20', "starting_weapon"])
self.weapon = dice.roll_random_table(chargen_table['1d20', "starting_weapon"])
self.equipment = [
"ration",
"ration",
roll_random_table(chargen_table['1d20', "dungeoning gear"]),
roll_random_table(chargen_table['1d20', "dungeoning gear"]),
roll_random_table(chargen_table['1d20', "general gear 1"]),
roll_random_table(chargen_table['1d20', "general gear 2"]),
dice.roll_random_table(chargen_table['1d20', "dungeoning gear"]),
dice.roll_random_table(chargen_table['1d20', "dungeoning gear"]),
dice.roll_random_table(chargen_table['1d20', "general gear 1"]),
dice.roll_random_table(chargen_table['1d20', "general gear 2"]),
]
def build_desc(self):
"""
Generate a backstory / description paragraph from random elements.
"""
return (
f"{self.background.title()}. Wears {self.clothing} clothes, and has {self.speech} "
f"speech. Has a {self.physique} physique, a {self.face} face, {self.skin} skin and "
f"{self.hair} hair. Is {self.virtue}, but {self.vice}. Has been {self.misfortune} in "
f"the past. Favors {self.alignment}."
)
def adjust_attribute(self, source_attribute, target_attribute, value):
"""
Redistribute bonus from one attribute to another. The resulting values
@ -257,3 +425,175 @@ class CharacterGeneration:
permanently.
"""
character.key = self.name
character.strength = self.strength
character.dexterity = self.dexterity
character.constitution = self.constitution
character.intelligence = self.intelligence
character.wisdom = self.wisdom
character.charisma = self.charisma
character.armor = self.armor_bonus
# character.exploration_speed = self.exploration_speed
# character.combat_speed = self.combat_speed
character.hp = self.hp
character.level = self.level
character.xp = self.xp
character.db.desc = self.build_desc()
if self.weapon:
character.equipment.add(self.weapon)
character.equipment.wield(self.weapon)
if self.shield:
character.equipment.add(self.shield)
character.equipment.wield(self.shield)
if self.armor:
character.equipment.add(self.armor)
character.equipment.wear(self.armor)
if self.helmet:
character.equipment.add(self.helmet)
character.equipment.wear(self.helmet)
# character improvement
class EvAdventureImprovement:
"""
Handle XP gains and level upgrades. Grouped in a class in order to
make it easier to override the mechanism.
"""
xp_per_level = 1000
amount_of_abilities_to_upgrade = 3
max_ability_bonus = 10 # bonus +10, defense 20
@staticmethod
def add_xp(character, xp):
"""
Add new XP.
Args:
character (Character): The character to improve.
xp (int): The amount of gained XP.
Returns:
bool: If a new level was reached or not.
Notes:
level 1 -> 2 = 1000 XP
level 2 -> 3 = 2000 XP etc
"""
character.xp += xp
next_level_xp = character.level * xp_per_level
return character.xp >= next_level_xp
@staticmethod
def level_up(character, *abilities):
"""
Perform the level-up action.
Args:
character (Character): The entity to level-up.
*abilities (str): A set of abilities (like 'strength', 'dexterity' (normally 3)
to upgrade by 1. Max is usually +10.
Notes:
We block increases above a certain value, but we don't raise an error here, that
will need to be done earlier, when the user selects the ability to increase.
"""
dice = EvAdventureRollEngine()
character.level += 1
for ability in set(abilities[:amount_of_abilities_to_upgrades]):
# limit to max amount allowed, each one unique
try:
# set at most to the max bonus
current_bonus = getattr(character, ability)
setattr(character, ability, min(max_ability_bonus, current_bonus + 1))
except AttributeError:
pass
new_hp_max = max(character.max_hpdice.roll(f"{character.level}d8"))
# character sheet visualization
class EvAdventureCharacterSheet:
"""
Generate a character sheet. This is grouped in a class in order to make
it easier to override the look of the sheet.
"""
sheet = """
+----------------------------------------------------------------------------+
| Name: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |
+----------------------------------------------------------------------------+
| STR: x2xxxxx DEX: x3xxxxx CON: x4xxxxx WIS: x5xxxxx CHA: x6xxxxx |
+----------------------------------------------------------------------------+
| HP: x7xxxxx XP: x8xxxxx Exploration speed: x9x Combat speed: xAx |
+----------------------------------------------------------------------------+
| Desc: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |
| xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxBxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |
| xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |
+----------------------------------------------------------------------------+
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
| cccccccccccccccccccccccccccccccccc1ccccccccccccccccccccccccccccccccccccccc |
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
+----------------------------------------------------------------------------+
"""
@staticmethod
def get(character):
"""
Generate a character sheet from the character's stats.
"""
equipment = character.equipment.wielded + character.equipment.worn + character.carried
# divide into chunks of max 10 length (to go into two columns)
equipment_table = EvTable(
table=[equipment[i: i + 10] for i in range(0, len(equipment), 10)]
)
form = EvForm({"FORMCHAR": 'x', "TABLECHAR": 'c', "SHEET": sheet})
form.map(
cells={
1: character.key,
2: f"+{character.strength}({character.strength + 10})",
3: f"+{character.dexterity}({character.dexterity + 10})",
4: f"+{character.constitution}({character.constitution + 10})",
5: f"+{character.wisdom}({character.wisdom + 10})",
6: f"+{character.charisma}({character.charisma + 10})",
7: f"{character.hp}/{character.hp_max}",
8: character.xp,
9: character.exploration_speed,
'A': character.combat_speed,
'B': character.db.desc,
},
tables={
1: equipment_table,
}
)
return str(form)
# singletons
# access sheet as rules.character_sheet.get(character)
character_sheet = CharacterSheet()
# access rolls e.g. with rules.dice.opposed_saving_throw(...)
dice = EvAdventureRollEngine()
# access improvement e.g. with rules.improvement.add_xp(character, xp)
improvement = EvAdventureImprovement()

View 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

View file

@ -2,46 +2,5 @@
Various utilities.
"""
from random import randint
def roll(roll_string, max_number=10):
"""
NOTE: In evennia/contribs/rpg/dice/ is a more powerful dice roller with
more features, such as modifiers, secret rolls etc. This is much simpler and only
gets a simple sum of normal rpg-dice.
Args:
roll_string (str): A roll using standard rpg syntax, <number>d<diesize>, like
1d6, 2d10 etc. Max die-size is 1000.
max_number (int): The max number of dice to roll. Defaults to 10, which is usually
more than enough.
Returns:
int: The rolled result - sum of all dice rolled.
Raises:
TypeError: If roll_string is not on the right format or otherwise doesn't validate.
Notes:
Since we may see user input to this function, we make sure to validate the inputs (we
wouldn't bother much with that if it was just for developer use).
"""
max_diesize = 1000
roll_string = roll_string.lower()
if 'd' not in roll_string:
raise TypeError(f"Dice roll '{roll_string}' was not recognized. Must be `<number>d<dicesize>`.")
number, diesize = roll_string.split('d', 1)
try:
number = int(number)
diesize = int(diesize)
except Exception:
raise TypeError(f"The number and dice-size of '{roll_string}' must be numerical.")
if 0 < number > max_number:
raise TypeError(f"Invalid number of dice rolled (must be between 1 and {max_number})")
if 0 < diesize > max_diesize:
raise TypeError(f"Invalid die-size used (must be between 1 and {max_diesize} sides)")
# At this point we know we have valid input - roll and all dice together
return sum(randint(1, diesize) for _ in range(number))

View file

@ -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

View file

@ -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

View file

@ -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 = {

View file

@ -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):

View file

@ -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))