From a07ef8e3c4680ae20729a39a009eb1aa539284a3 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 16 Mar 2022 00:12:21 +0100 Subject: [PATCH] First layout of evadventure --- .../contrib/tutorials/evadventure/README.md | 30 ++ .../contrib/tutorials/evadventure/__init__.py | 0 .../tutorials/evadventure/characters.py | 0 .../contrib/tutorials/evadventure/combat.py | 0 .../contrib/tutorials/evadventure/commands.py | 0 .../contrib/tutorials/evadventure/objects.py | 0 .../tutorials/evadventure/random_tables.py | 354 ++++++++++++++++++ .../contrib/tutorials/evadventure/rooms.py | 0 .../contrib/tutorials/evadventure/rules.py | 259 +++++++++++++ .../contrib/tutorials/evadventure/utils.py | 47 +++ .../tutorials/evadventure/world_batchfile.py | 0 11 files changed, 690 insertions(+) create mode 100644 evennia/contrib/tutorials/evadventure/README.md create mode 100644 evennia/contrib/tutorials/evadventure/__init__.py create mode 100644 evennia/contrib/tutorials/evadventure/characters.py create mode 100644 evennia/contrib/tutorials/evadventure/combat.py create mode 100644 evennia/contrib/tutorials/evadventure/commands.py create mode 100644 evennia/contrib/tutorials/evadventure/objects.py create mode 100644 evennia/contrib/tutorials/evadventure/random_tables.py create mode 100644 evennia/contrib/tutorials/evadventure/rooms.py create mode 100644 evennia/contrib/tutorials/evadventure/rules.py create mode 100644 evennia/contrib/tutorials/evadventure/utils.py create mode 100644 evennia/contrib/tutorials/evadventure/world_batchfile.py diff --git a/evennia/contrib/tutorials/evadventure/README.md b/evennia/contrib/tutorials/evadventure/README.md new file mode 100644 index 0000000000..6e308c37d6 --- /dev/null +++ b/evennia/contrib/tutorials/evadventure/README.md @@ -0,0 +1,30 @@ +# EvAdventure + +Contrib by Griatch 2022 + +A complete example MUD using Evennia. This is the final result of what is +implemented if you follow the Getting-Started tutorial. It's recommended +that you follow the tutorial step by step and write your own code. But if +you prefer you can also pick apart or use this as a starting point for your +own game. + +## Features + +- Uses a MUD-version of the [Knave](https://rpggeek.com/rpg/50827/knave) old-school + fantasy ruleset by Ben Milton (classless and overall compatible with early + edition D&D), released under the Creative Commons Attribution (all uses, + including commercial are allowed + as long as attribution is given). +- Character creation using an editable character sheet +- Weapons, effects, healing and resting +- Two alternative combat systems (turn-based and twitch based) +- Magic (three spells) +- NPC/mobs with simple AI. +- Simple Quest system. +- Small game world. +- Coded using best Evennia practices, with unit tests. + + +## Installation + +TODO diff --git a/evennia/contrib/tutorials/evadventure/__init__.py b/evennia/contrib/tutorials/evadventure/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/contrib/tutorials/evadventure/characters.py b/evennia/contrib/tutorials/evadventure/characters.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/contrib/tutorials/evadventure/combat.py b/evennia/contrib/tutorials/evadventure/combat.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/contrib/tutorials/evadventure/commands.py b/evennia/contrib/tutorials/evadventure/commands.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/contrib/tutorials/evadventure/objects.py b/evennia/contrib/tutorials/evadventure/objects.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/contrib/tutorials/evadventure/random_tables.py b/evennia/contrib/tutorials/evadventure/random_tables.py new file mode 100644 index 0000000000..19604ec922 --- /dev/null +++ b/evennia/contrib/tutorials/evadventure/random_tables.py @@ -0,0 +1,354 @@ +""" +Random tables - adopted from _Knave_. + +""" + +# Character generation tables + +character_generation = { + "physique": [ + "athletic", + "brawny", + "corpulent", + "delicate", + "gaunt", + "hulking", + "lanky", + "ripped", + "rugged", + "scrawny", + "short", + "sinewy", + "slender", + "flabby", + "statuesque", + "stout", + "tiny", + "towering", + "willowy", + "wiry", + ], + "face": [ + "bloated", + "blunt", + "bony", + "chiseled", + "delicate", + "elongated", + "patrician", + "pinched", + "hawkish", + "broken", + "impish", + "narrow", + "ratlike", + "round", + "sunken", + "sharp", + "soft", + "square", + "wide", + "wolfish", + ], + "skin": [ + "battle scar", + "birthmark", + "burn scar", + "dark", + "makeup", + "oily", + "pale", + "perfect", + "pierced", + "pockmarked", + "reeking", + "tattooed", + "rosy", + "rough", + "sallow", + "sunburned", + "tanned", + "war paint", + "weathered", + "whip scar", + ], + "hair": [ + "bald", + "braided", + "bristly", + "cropped", + "curly", + "disheveled", + "dreadlocks", + "filthy", + "frizzy", + "greased", + "limp", + "long", + "luxurious", + "mohawk", + "oily", + "ponytail", + "silky", + "topknot", + "wavy", + "wispy", + ], + "clothing": [ + "antique", + "bloody", + "ceremonial", + "decorated", + "eccentric", + "elegant", + "fashionable", + "filthy", + "flamboyant", + "stained", + "foreign", + "frayed", + "frumpy", + "livery", + "oversized", + "patched", + "perfumed", + "rancid", + "torn", + "undersized", + ], + "virtue": [ + "ambitious", + "cautious", + "courageous", + "courteous", + "curious", + "disciplined", + "focused", + "generous", + "gregarious", + "honest", + "honorable", + "humble", + "idealistic", + "just", + "loyal", + "merciful", + "righteous", + "serene", + "stoic", + "tolerant", + ], + "vice": [ + "aggressive", + "arrogant", + "bitter", + "cowardly", + "cruel", + "deceitful", + "flippant", + "gluttonous", + "greedy", + "irascible", + "lazy", + "nervous", + "prejudiced", + "reckless", + "rude", + "suspicious", + "vain", + "vengeful", + "wasteful", + "whiny", + ], + "speech": [ + "blunt", + "booming", + "breathy", + "cryptic", + "drawling", + "droning", + "flowery", + "formal", + "gravelly", + "hoarse", + "mumbling", + "precise", + "quaint", + "rambling", + "rapid-fire", + "dialect", + "slow", + "squeaky", + "stuttering", + "whispery", + ], + "background": [ + "alchemist", + "beggar", + "butcher", + "burglar", + "charlatan", + "cleric", + "cook", + "cultist", + "gambler", + "herbalist", + "magician", + "mariner", + "mercenary", + "merchant", + "outlaw", + "performer", + "pickpocket", + "smuggler", + "student", + "tracker", + ], + "mifortuntes": [ + "abandoned", + "addicted", + "blackmailed", + "condemned", + "cursed", + "defrauded", + "demoted", + "discredited", + "disowned", + "exiled", + "framed", + "haunted", + "kidnapped", + "mutilated", + "poor", + "pursued", + "rejected", + "replaced", + "robbed", + "suspected", + ], + "alignment": [ + ('1-5', "law"), + ('6-15', "neutrality"), + ('16-20', "chaos"), + ], + "armor": [ + ('1-3', "no armor"), + ('4-14', "gambeson"), + ('15-19', "brigandine"), + ('20', "chain"), + ], + "helmets and shields": [ + ('1-13', "no helmet"), + ('14-16', "helmet"), + ('17-19', "shield"), + ('20', "helmet and shield"), + ], + "starting weapon": [ # note: these are all d6 dmg weapons + ('1-7', "dagger", + '8-13', "club", + '14-20', "staff"), + ], + "dungeoning gear": [ + "rope, 50ft", + "pulleys", + "candles, 5", + "chain, 10ft", + "chalk, 10", + "crowbar", + "tinderbox", + "grap. hook", + "hammer", + "waterskin", + "lantern", + "lamp oil", + "padlock", + "manacles", + "mirror", + "pole, 10ft", + "sack", + "tent", + "spikes, 5", + "torches, 5", + ], + "general gear 1": [ + "air bladder", + "bear trap", + "shovel", + "bellows", + "grease", + "saw", + "bucket", + "caltrops", + "chisel", + "drill", + "fish. rod", + "marbles", + "glue", + "pick", + "hourglass", + "net", + "tongs", + "lockpicks", + "metal file", + "nails", + ], + "general gear 2": [ + "incense", + "sponge", + "lens", + "perfume", + "horn", + "bottle", + "soap", + "spyglass", + "tar pot", + "twine", + "fake jewels", + "blank book", + "card deck", + "dice set", + "cook pots", + "face paint", + "whistle", + "instrument", + "quill & ink", + "small bell", + ], + "name": [ + "Abbo", "Adelaide", "Ellis", "Eleanor", "Lief", "Luanda", "Ablerus", "Agatha", + "Eneto", "Elizabeth", "Luke", "Lyra", "Acot", "Aleida", "Enio", "Elspeth", "Martin", + "Mabel", "Alexander", "Alexia", "Eral", "Emeline", "Merrick", "Maerwynn", "Almanzor", + "Alianor", "Erasmus", "Emma", "Mortimer", "Malkyn", "Althalos", "Aline", "Eustace", + "Emmony", "Ogden", "Margaret", "Ancelot", "Alma", "Everard", "Enna", "Oliver", "Margery", + "Asher", "Alys", "Faustus", "Enndolynn", "Orion", "Maria", "Aster", "Amabel", "Favian", + "Eve", "Oswald", "Marion", "Balan", "Amice", "Fendrel", "Evita", "Pelagon", "Matilda", + "Balthazar", "Anastas", "Finn", "Felice", "Pello", "Millicent", "Barat", "Angmar", + "Florian", "Fern", "Peyton", "Mirabelle", "Bartholomew", "Annabel", "Francis", "Floria", + "Philip", "Muriel", "Basil", "Arabella", "Frederick", "Fredegonde", "Poeas", "Nabarne", + "Benedict", "Ariana", "Gaidon", "Gillian", "Quinn", "Nell", "Berinon", "Ayleth", "Gavin", + "Gloriana", "Ralph", "Nesea", "Bertram", "Barberry", "Geoffrey", "Godeleva", "Randolph", + "Niree", "Beves", "Barsaba", "Gerard", "Godiva", "Reginald", "Odette", "Bilmer", + "Basilia", "Gervase", "Gunnilda", "Reynold", "Odila", "Blanko", "Beatrix", "Gilbert", + "Gussalen", "Richard", "Oria", "Bodo", "Benevolence", "Giles", "Gwendolynn", "Robert", + "Osanna", "Borin", "Bess", "Godfrey", "Hawise", "Robin", "Ostrythe", "Bryce", "Brangian", + "Gregory", "Helena", "Roger", "Ottilia", "Carac", "Brigida", "Gringoire", "Helewise", + "Ronald", "Panope", "Caspar", "Brunhild", "Gunthar", "Hester", "Rowan", "Paternain", + "Cassius", "Camilla", "Guy", "Hildegard", "Rulf", "Pechel", "Cedric", "Canace", "Gyras", + "Idony", "Sabin", "Pepper", "Cephalos", "Cecily", "Hadrian", "Isabella", "Sevrin", + "Petronilla", "Chadwick", "Cedany", "Hedelf", "Iseult", "Silas", "Phrowenia", "Charillos", + "Christina", "Hewelin", "Isolde", "Simon", "Poppy", "Charles", "Claramunda", "Hilderith", + "Jacquelyn", "Solomon", "Quenell", "Chermon", "Clarice", "Humbert", "Jasmine", "Stephen", + "Raisa", "Clement", "Clover", "Hyllus", "Jessamine", "Terrowin", "Reyna", "Clifton", + "Collette", "Ianto", "Josselyn", "Thomas", "Rixende", "Clovis", "Constance", "Ibykos", + "Juliana", "Tristan", "Rosamund", "Cyon", "Damaris", "Inigo", "Karitate", "Tybalt", + "Rose", "Dain", "Daphne", "Itylus", "Katelyn", "Ulric", "Ryia", "Dalmas", "Demona", + "James", "Katja", "Walter", "Sarah", "Danor", "Dimia", "Jasper", "Katrina", "Wander", + "Seraphina", "Destrian", "Dione", "Jiles", "Kaylein", "Warin", "Thea", "Domeka", + "Dorothea", "Joffridus", "Kinna", "Waverly", "Trillby", "Donald", "Douce", "Jordan", + "Krea", "Willahelm", "Wendel", "Doran", "Duraina", "Joris", "Kypris", "William", + "Wilberga", "Dumphey", "Dyota", "Josef", "Landerra", "Wimarc", "Winifred", "Eadmund", + "Eberhild", "Laurence", "Larraza", "Wystan", "Wofled", "Eckardus", "Edelot", "Leofrick", + "Linet", "Xalvador", "Wymarc", "Edward", "Edyva", "Letholdus", "Loreena", "Zane", "Ysmay", + ], +} + diff --git a/evennia/contrib/tutorials/evadventure/rooms.py b/evennia/contrib/tutorials/evadventure/rooms.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/contrib/tutorials/evadventure/rules.py b/evennia/contrib/tutorials/evadventure/rules.py new file mode 100644 index 0000000000..b8fc1e8816 --- /dev/null +++ b/evennia/contrib/tutorials/evadventure/rules.py @@ -0,0 +1,259 @@ +""" +MUD ruleset based on the _Knave_ OSR tabletop RPG by Ben Milton (modified for MUD use). + +The rules are divided into three parts: + +- 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. + +""" +from dataclasses import dataclass +from .utils import roll +from .random_tables import character_generation as chargen_table + + +# Basic rolls + +def saving_throw(bonus, advantage=False, disadvantage=False): + """ + 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. + + """ + 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 + + +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. + + The Attribute bonus is generated by rolling the lowest value of 3d6. + + Returns: + int: The randomly generated Attribute bonus. + + """ + return min(roll("1d6"), roll("1d6"), roll("1d6")) + + +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: + """ + 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 + character at the end. This class instance can also be saved temporarily to make sure a user + is not losing their half-created character. + + Note: + Unlike standard Knave, characters will come out more similar here. This is because in + a table top game it's fun to roll randomly and have to live with a crappy roll - but + online players can (and usually will) just disconnect and reroll until they get values + they are happy with. + + So, in standard Knave, the character's attribute bonus is rolled randomly and will give a + value 1-6; and there is no guarantee for 'equal' starting characters. Instead we + homogenize the results to a flat +2 bonus and let people redistribute the + points afterwards. This also allows us to show off some more advanced concepts in the + chargen menu, but you can also easily make it random like in base Knave by using the + (currently unused, but included) `roll_attribute_bonus` function above to get the bonus + instead of the flat +2. + + In the same way, Knave uses a d8 roll to get the initial hit points. Instead we use a + flat max of 8 HP to start, in order to give players a little more survivability. + + We *will* roll random start equipment though. Contrary to standard Knave, we'll also + randomly assign the starting weapon among a small selection of equal-dmg weapons (since + there is no GM to adjudicate a different choice). + + """ + def __init__(self): + """ + Initialize starting values + + """ + # name will likely be modified later + self.name = 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.armor = CharAttribute(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']) + + # same for all + self.exploration_speed = 120 + self.combat_speed = 40 + self.hp = 0 + self.xp = 0 + self.level = 1 + + # random equipment + self.armor = roll_random_table('1d20', chargen_table['armor']) + + _helmet_and_shield = 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.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"]), + ] + + def adjust_attribute(self, source_attribute, target_attribute, value): + """ + Redistribute bonus from one attribute to another. The resulting values + must not be lower than +1 and not above +6. + + Args: + source_attribute (str): The name of the attribute to deduct bonus from, like 'strength' + target_attribute (str): The attribute to give the bonus to, like 'dexterity'. + value (int): How much to change. This is always 1 for the current chargen. + + Raises: + ValueError: On input error, using invalid values etc. + + Notes: + We assume the strings are provided by the chargen, so we don't do + much input validation here, we do make sure we don't overcharge ourselves though. + + """ + # we use getattr() to fetch the CharaAttribute of e.g. the .strength property etc + source_current_bonus = getattr(self, source_attribute).bonus + target_current_bonus = getattr(self, target_attribute).bonus + + if source_current_val - value < 1: + raise ValueError(f"You can't reduce the {source_attribute} bonus below +1.") + if target_current_val + value > 6: + raise ValueError(f"You can't increase the {target_attribute} bonus above +6.") + + # all is good, apply the change. + setattr(self, source_attribute, CharAttribute(bonus=source_current_val - value)) + setattr(self, target_attribute, CharAttribute(bonus=source_current_val + value)) + + def apply(self, character): + """ + Once the chargen is complete, call this to transfer all the data to the character + permanently. + + """ diff --git a/evennia/contrib/tutorials/evadventure/utils.py b/evennia/contrib/tutorials/evadventure/utils.py new file mode 100644 index 0000000000..13e145d108 --- /dev/null +++ b/evennia/contrib/tutorials/evadventure/utils.py @@ -0,0 +1,47 @@ +""" +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/contrib/tutorials/evadventure/world_batchfile.py b/evennia/contrib/tutorials/evadventure/world_batchfile.py new file mode 100644 index 0000000000..e69de29bb2